feat: add website template (#6470)

Adds the new website template for 3.0
This commit is contained in:
Paul
2024-05-23 13:48:41 -03:00
committed by GitHub
parent 661a4a099d
commit 85bfca79ef
330 changed files with 17114 additions and 35420 deletions

View File

@@ -1,3 +1,9 @@
build build
dist / media dist / media
node_modules.DS_Store.env.next.vercel node_modules
.DS_Store
.env
.next
.vercel
media

View File

@@ -12,15 +12,12 @@ RUN yarn build
FROM base as runtime FROM base as runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PAYLOAD_CONFIG_PATH=dist/payload.config.js
WORKDIR /home/node/app WORKDIR /home/node/app
COPY package*.json ./ COPY package*.json ./
COPY yarn.lock ./ COPY yarn.lock ./
RUN yarn install --production RUN yarn install --production
COPY --from=builder /home/node/app/dist ./dist
COPY --from=builder /home/node/app/build ./build
EXPOSE 3000 EXPOSE 3000

View File

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

View File

@@ -1,19 +0,0 @@
const policies = {
'child-src': ["'self'"],
'connect-src': ["'self'", 'https://maps.googleapis.com'],
'default-src': ["'self'"],
'font-src': ["'self'"],
'frame-src': ["'self'"],
'img-src': ["'self'", 'https://raw.githubusercontent.com'],
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https://maps.googleapis.com'],
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
}
module.exports = Object.entries(policies)
.map(([key, value]) => {
if (Array.isArray(value)) {
return `${key} ${value.join(' ')}`
}
return ''
})
.join('; ')

View File

@@ -1,36 +0,0 @@
import fs from 'fs'
import path from 'path'
// Run this script to eject the front-end from this template
// This will remove all template-specific files and directories
// See `yarn eject` in `package.json` for the exact command
// See `./README.md#eject` for more information
const files = ['./next.config.js', './next-env.d.ts', './redirects.js']
const directories = ['./src/app']
const eject = async (): Promise<void> => {
files.forEach((file) => {
fs.unlinkSync(path.join(__dirname, file))
})
directories.forEach((directory) => {
fs.rm(path.join(__dirname, directory), { recursive: true }, (err) => {
if (err) throw err
})
})
// create a new `./src/server.ts` file
// use contents from `./src/server.default.ts`
const serverFile = path.join(__dirname, './src/server.ts')
const serverDefaultFile = path.join(__dirname, './src/server.default.ts')
fs.copyFileSync(serverDefaultFile, serverFile)
// remove `'plugin:@next/next/recommended', ` from `./.eslintrc.js`
const eslintConfigFile = path.join(__dirname, './.eslintrc.js')
const eslintConfig = fs.readFileSync(eslintConfigFile, 'utf8')
const updatedEslintConfig = eslintConfig.replace(`'plugin:@next/next/recommended', `, '')
fs.writeFileSync(eslintConfigFile, updatedEslintConfig, 'utf8')
}
eject()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,42 +1,9 @@
import { withPayload } from '@payloadcms/next/withPayload'
import redirects from './redirects.js'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const ContentSecurityPolicy = require('./csp')
const redirects = require('./redirects')
const nextConfig = { const nextConfig = {
async headers() {
const headers = []
// Prevent search engines from indexing the site if it is not live
// This is useful for staging environments before they are ready to go live
// To allow robots to crawl the site, use the `NEXT_PUBLIC_IS_LIVE` env variable
// You may want to also use this variable to conditionally render any tracking scripts
if (!process.env.NEXT_PUBLIC_IS_LIVE) {
headers.push({
headers: [
{
key: 'X-Robots-Tag',
value: 'noindex',
},
],
source: '/:path*',
})
}
// Set the `Content-Security-Policy` header as a security measure to prevent XSS attacks
// It works by explicitly whitelisting trusted sources of content for your website
// This will block all inline scripts and styles except for those that are allowed
headers.push({
headers: [
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy,
},
],
source: '/(.*)',
})
return headers
},
images: { images: {
domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL] domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL]
.filter(Boolean) .filter(Boolean)
@@ -44,7 +11,6 @@ const nextConfig = {
}, },
reactStrictMode: true, reactStrictMode: true,
redirects, redirects,
swcMinify: true,
} }
module.exports = nextConfig export default withPayload(nextConfig)

View File

@@ -1,7 +0,0 @@
{
"$schema": "https://json.schemastore.org/nodemon.json",
"exec": "ts-node src/server.ts -- -I",
"ext": "ts",
"ignore": ["src/app"],
"stdin": false
}

View File

@@ -1,61 +1,82 @@
{ {
"name": "@payloadcms/template-website", "name": "@payloadcms/template-website",
"description": "Website template for Payload",
"version": "1.0.0", "version": "1.0.0",
"main": "dist/server.js", "description": "Website template for Payload",
"license": "MIT", "license": "MIT",
"type": "module",
"scripts": { "scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts nodemon", "build": "next build",
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts ts-node src/server.ts", "dev": "next dev",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts payload build", "dev:prod": "rm -rf .next && pnpm build && pnpm serve",
"build:server": "tsc --project tsconfig.server.json", "generate:types": "payload generate:types",
"build:next": "cross-env PAYLOAD_CONFIG_PATH=dist/payload/payload.config.js NEXT_BUILD=true node dist/server.js", "ii": "pnpm --ignore-workspace install",
"build": "cross-env NODE_ENV=production yarn build:payload && yarn build:server && yarn copyfiles && yarn build:next",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload/payload.config.js NODE_ENV=production node dist/server.js",
"eject": "yarn remove next react react-dom @next/eslint-plugin-next && ts-node eject.ts",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,js}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts payload generate:types",
"generate:graphQLSchema": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src", "lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src", "lint:fix": "eslint --fix --ext .ts,.tsx src",
"payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts payload" "payload": "payload",
"reinstall": "rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true next dev",
"serve": "next start"
}, },
"dependencies": { "dependencies": {
"@payloadcms/bundler-webpack": "^1.0.0", "@lexical/list": "^0.15",
"@payloadcms/db-mongodb": "^1.0.0", "@lexical/react": "^0.15",
"@payloadcms/db-postgres": "^0.1.9", "@lexical/rich-text": "^0.15",
"@payloadcms/plugin-cloud": "^3.0.0", "@lexical/utils": "^0.15",
"@payloadcms/plugin-nested-docs": "^1.0.8", "@payloadcms/db-mongodb": "3.0.0-beta.34",
"@payloadcms/plugin-redirects": "^1.0.0", "@payloadcms/db-postgres": "3.0.0-beta.34",
"@payloadcms/plugin-seo": "^1.0.10", "@payloadcms/live-preview-react": "3.0.0-beta.34",
"@payloadcms/richtext-slate": "^1.0.0", "@payloadcms/next": "3.0.0-beta.34",
"@payloadcms/plugin-cloud": "3.0.0-beta.34",
"@payloadcms/plugin-form-builder": "3.0.0-beta.34",
"@payloadcms/plugin-nested-docs": "3.0.0-beta.34",
"@payloadcms/plugin-redirects": "3.0.0-beta.34",
"@payloadcms/plugin-seo": "3.0.0-beta.34",
"@payloadcms/richtext-lexical": "3.0.0-beta.34",
"@payloadcms/ui": "3.0.0-beta.34",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^4.17.1", "geist": "^1.3.0",
"next": "13.5.2", "graphql": "^16.8.1",
"payload": "^2.0.7", "jsonwebtoken": "9.0.1",
"lucide-react": "^0.378.0",
"next": "14.3.0-canary.68",
"payload": "3.0.0-beta.34",
"payload-admin-bar": "^1.0.6", "payload-admin-bar": "^1.0.6",
"prism-react-renderer": "^2.3.1",
"qs": "6.11.2", "qs": "6.11.2",
"react": "^18.2.0", "react": "beta",
"react-dom": "^18.2.0", "react-dom": "beta",
"react-hook-form": "7.45.4", "react-hook-form": "7.45.4",
"react-router-dom": "5.3.4" "react-router-dom": "5.3.4",
"sharp": "0.32.6",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@next/eslint-plugin-next": "^13.1.6", "@next/eslint-plugin-next": "^13.1.6",
"@payloadcms/eslint-config": "^1.1.1", "@payloadcms/eslint-config": "^1.1.1",
"@swc/core": "1.3.76", "@swc/core": "1.3.76",
"@tailwindcss/typography": "^0.5.13",
"@types/escape-html": "^1.0.2", "@types/escape-html": "^1.0.2",
"@types/express": "^4.17.9",
"@types/node": "18.11.3", "@types/node": "18.11.3",
"@types/qs": "^6.9.8", "@types/qs": "^6.9.8",
"@types/react": "18.0.21", "@types/react": "^18.3.0",
"autoprefixer": "^10.4.19",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"nodemon": "^2.0.6", "postcss": "^8.4.38",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"slate": "0.91.4", "tailwindcss": "^3.4.3",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "^4.8.4" "typescript": "^5.4.2"
},
"overrides": {
"@types/react": "18.2.74"
} }
} }

9882
templates/website/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,9 +1,4 @@
// Note: This will not work in dev mode and will throw an error upon startup const redirects = async () => {
// This is because the Payload APIs are not yet running when the Next.js server starts
// This is not a problem in production as Payload is booted up before building Next.js
// For this reason the errors can be silently ignored in dev mode
module.exports = async () => {
const internetExplorerRedirect = { const internetExplorerRedirect = {
destination: '/ie-incompatible.html', destination: '/ie-incompatible.html',
has: [ has: [
@@ -17,65 +12,9 @@ module.exports = async () => {
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
} }
try { const redirects = [internetExplorerRedirect]
const redirectsRes = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/redirects?limit=1000&depth=1`,
)
const redirectsData = await redirectsRes.json() return redirects
const { docs } = redirectsData
let dynamicRedirects = []
if (docs) {
docs.forEach((doc) => {
const { from, to: { type, reference, url } = {} } = doc
let source = from
.replace(process.env.NEXT_PUBLIC_SERVER_URL, '')
.split('?')[0]
.toLowerCase()
if (source.endsWith('/')) source = source.slice(0, -1) // a trailing slash will break this redirect
let destination = '/'
if (type === 'custom' && url) {
destination = url.replace(process.env.NEXT_PUBLIC_SERVER_URL, '')
}
if (
type === 'reference' &&
typeof reference.value === 'object' &&
reference?.value?._status === 'published'
) {
destination = `${process.env.NEXT_PUBLIC_SERVER_URL}/${
reference.relationTo !== 'pages' ? `${reference.relationTo}/` : ''
}${reference.value.slug}`
}
const redirect = {
destination,
permanent: true,
source,
}
if (source.startsWith('/') && destination && source !== destination) {
return dynamicRedirects.push(redirect)
}
return
})
}
const redirects = [internetExplorerRedirect, ...dynamicRedirects]
return redirects
} catch (error) {
if (process.env.NODE_ENV === 'production') {
console.error(`Error configuring redirects: ${error}`) // eslint-disable-line no-console
}
return []
}
} }
export default redirects

View File

@@ -0,0 +1,76 @@
import type { Metadata } from 'next'
import configPromise from '@payload-config'
import { getPayloadHMR } from '@payloadcms/next/utilities'
import { draftMode, headers } from 'next/headers'
import React from 'react'
import type { Page } from '../../../../payload-types'
import { Blocks } from '../../../components/Blocks'
import { Hero } from '../../../components/Hero'
import { PayloadRedirects } from '../../../components/PayloadRedirects'
import { generateMeta } from '../../../utilities/generateMeta'
export async function generateStaticParams() {
const payload = await getPayloadHMR({ config: configPromise })
const pages = await payload.find({
collection: 'pages',
draft: false,
limit: 1000,
overrideAccess: false,
})
return pages.docs?.map(({ slug }) => slug)
}
export default async function Page({ params: { slug = 'home' } }) {
const url = '/' + slug
const page = await queryPageBySlug({
slug,
})
if (!page) {
return <PayloadRedirects url={url} />
}
const { hero, layout } = page
return (
<article className="pt-16 pb-24">
<Hero {...hero} />
<Blocks blocks={layout} />
</article>
)
}
export async function generateMetadata({ params: { slug = 'home' } }): Promise<Metadata> {
const page = await queryPageBySlug({
slug,
})
return generateMeta({ doc: page })
}
const queryPageBySlug = async ({ slug }: { slug: string }) => {
const { isEnabled: draft } = draftMode()
const payload = await getPayloadHMR({ config: configPromise })
const user = draft ? await payload.auth({ headers: headers() }) : undefined
const result = await payload.find({
collection: 'pages',
draft,
limit: 1,
overrideAccess: false,
user,
where: {
slug: {
equals: slug,
},
},
})
return result.docs?.[0] || null
}

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import configPromise from '@payload-config'
import { getPayloadHMR } from '@payloadcms/next/utilities'
import { draftMode, headers } from 'next/headers'
import { notFound } from 'next/navigation'
import React from 'react'
import RichText from 'src/app/components/RichText'
import type { Post } from '../../../../../payload-types'
import { PayloadRedirects } from '../../../../components/PayloadRedirects'
import { PostHero } from '../../../../heros/PostHero'
import { generateMeta } from '../../../../utilities/generateMeta'
import PageClient from './page.client'
export async function generateStaticParams() {
const payload = await getPayloadHMR({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
draft: false,
limit: 1000,
overrideAccess: false,
})
return posts.docs?.map(({ slug }) => slug)
}
export default async function Post({ params: { slug = '' } }) {
const url = '/posts/' + slug
const post = await queryPostBySlug({ slug })
if (!post) {
return <PayloadRedirects url={url} />
}
return (
<article className="pt-16 pb-16">
<PageClient />
<PostHero post={post} />
<div className="flex flex-col gap-4 pt-8">
<div className="container lg:grid lg:grid-cols-[1fr_48rem_1fr] grid-rows-[1fr]">
<RichText
className="lg:grid lg:grid-cols-subgrid col-start-1 col-span-3 grid-rows-[1fr]"
content={post.content}
enableGutter={false}
/>
</div>
</div>
</article>
)
}
export async function generateMetadata({ params: { slug } }): Promise<Metadata> {
const post = await queryPostBySlug({ slug })
return generateMeta({ doc: post })
}
const queryPostBySlug = async ({ slug }: { slug: string }) => {
const { isEnabled: draft } = draftMode()
const payload = await getPayloadHMR({ config: configPromise })
const user = draft ? await payload.auth({ headers: headers() }) : undefined
const result = await payload.find({
collection: 'posts',
draft,
limit: 1,
overrideAccess: false,
user,
where: {
slug: {
equals: slug,
},
},
})
return result.docs?.[0] || null
}

View File

@@ -0,0 +1,52 @@
import type { Metadata } from 'next/types'
import { CollectionArchive } from '@/components/CollectionArchive'
import { PageRange } from '@/components/PageRange'
import { Pagination } from '@/components/Pagination'
import configPromise from '@payload-config'
import { getPayloadHMR } from '@payloadcms/next/utilities'
import React from 'react'
export const dynamic = 'force-static'
export const revalidate = 600
export default async function Page() {
const payload = await getPayloadHMR({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
depth: 1,
limit: 12,
})
return (
<div className="pb-24">
<div className="container mb-8">
<div className="prose dark:prose-invert max-w-none">
<h1>Posts</h1>
</div>
</div>
<div className="container mb-8">
<PageRange
collection="posts"
currentPage={posts.page}
limit={12}
totalDocs={posts.totalDocs}
/>
</div>
<CollectionArchive posts={posts.docs} />
<div className="container">
{posts.totalPages > 1 && <Pagination page={posts.page} totalPages={posts.totalPages} />}
</div>
</div>
)
}
export function generateMetadata(): Metadata {
return {
title: `Payload Website Template Posts`,
}
}

View File

@@ -0,0 +1,70 @@
import type { Metadata } from 'next/types'
import { CollectionArchive } from '@/components/CollectionArchive'
import { PageRange } from '@/components/PageRange'
import { Pagination } from '@/components/Pagination'
import configPromise from '@payload-config'
import { getPayloadHMR } from '@payloadcms/next/utilities'
import React from 'react'
export const dynamic = 'force-static'
export const revalidate = 600
export default async function Page({ params: { pageNumber = 2 } }) {
const payload = await getPayloadHMR({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
depth: 1,
limit: 12,
page: pageNumber,
})
return (
<div className="pb-24">
<div className="container mb-8">
<div className="prose dark:prose-invert max-w-none">
<h1>Posts</h1>
</div>
</div>
<div className="container mb-8">
<PageRange
collection="posts"
currentPage={posts.page}
limit={12}
totalDocs={posts.totalDocs}
/>
</div>
<CollectionArchive posts={posts.docs} />
<div className="container">
{posts.totalPages > 1 && <Pagination page={posts.page} totalPages={posts.totalPages} />}
</div>
</div>
)
}
export function generateMetadata({ params: { pageNumber = 2 } }): Metadata {
return {
title: `Payload Website Template Posts Page ${pageNumber}`,
}
}
export async function generateStaticParams() {
const payload = await getPayloadHMR({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
depth: 0,
limit: 10,
})
const pages = []
for (let i = 1; i <= posts.totalPages; i++) {
pages.push(i)
}
return pages
}

View File

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

View File

@@ -1,18 +1,23 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { cn } from '@/utilities/cn'
import { GeistMono } from 'geist/font/mono'
import { GeistSans } from 'geist/font/sans'
import React from 'react' import React from 'react'
import { AdminBar } from './_components/AdminBar' import { AdminBar } from '../components/AdminBar'
import { Footer } from './_components/Footer' import { Footer } from '../components/Footer'
import { Header } from './_components/Header' import { Header } from '../components/Header'
import './_css/app.scss' import { LivePreviewListener } from '../components/LivePreviewListener'
import { Providers } from './_providers' import { Providers } from '../providers'
import { InitTheme } from './_providers/Theme/InitTheme' import { InitTheme } from '../providers/Theme/InitTheme'
import { mergeOpenGraph } from './_utilities/mergeOpenGraph' import { mergeOpenGraph } from '../utilities/mergeOpenGraph'
import './globals.css'
// eslint-disable-next-line no-restricted-exports, @typescript-eslint/require-await
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html className={cn(GeistSans.variable, GeistMono.variable)} lang="en" suppressHydrationWarning>
<head> <head>
<InitTheme /> <InitTheme />
<link href="/favicon.ico" rel="icon" sizes="32x32" /> <link href="/favicon.ico" rel="icon" sizes="32x32" />
@@ -21,10 +26,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body> <body>
<Providers> <Providers>
<AdminBar /> <AdminBar />
{/* @ts-expect-error */} <LivePreviewListener />
<Header /> <Header />
{children} {children}
{/* @ts-expect-error */}
<Footer /> <Footer />
</Providers> </Providers>
</body> </body>

View File

@@ -1,98 +0,0 @@
import type { Metadata } from 'next'
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import React from 'react'
import type { Page } from '../../../payload/payload-types'
import { staticHome } from '../../../payload/seed/home-static'
import { fetchDoc } from '../../_api/fetchDoc'
import { fetchDocs } from '../../_api/fetchDocs'
import { Blocks } from '../../_components/Blocks'
import { Hero } from '../../_components/Hero'
import { generateMeta } from '../../_utilities/generateMeta'
// Payload Cloud caches all files through Cloudflare, so we don't need Next.js to cache them as well
// This means that we can turn off Next.js data caching and instead rely solely on the Cloudflare CDN
// To do this, we include the `no-cache` header on the fetch requests used to get the data for this page
// But we also need to force Next.js to dynamically render this page on each request for preview mode to work
// See https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic
// If you are not using Payload Cloud then this line can be removed, see `../../../README.md#cache`
export const dynamic = 'force-dynamic'
export default async function Page({ params: { slug = 'home' } }) {
const { isEnabled: isDraftMode } = draftMode()
let page: Page | null = null
try {
page = await fetchDoc<Page>({
slug,
collection: 'pages',
draft: isDraftMode,
})
} catch (error) {
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// so swallow the error here and simply render the page with fallback data where necessary
// in production you may want to redirect to a 404 page or at least log the error somewhere
// console.error(error)
}
// if no `home` page exists, render a static one using dummy content
// you should delete this code once you have a home page in the CMS
// this is really only useful for those who are demoing this template
if (!page && slug === 'home') {
page = staticHome
}
if (!page) {
return notFound()
}
const { hero, layout } = page
return (
<React.Fragment>
<Hero {...hero} />
<Blocks
blocks={layout}
disableTopPadding={!hero || hero?.type === 'none' || hero?.type === 'lowImpact'}
/>
</React.Fragment>
)
}
export async function generateStaticParams() {
try {
const pages = await fetchDocs<Page>('pages')
return pages?.map(({ slug }) => slug)
} catch (error) {
return []
}
}
export async function generateMetadata({ params: { slug = 'home' } }): Promise<Metadata> {
const { isEnabled: isDraftMode } = draftMode()
let page: Page | null = null
try {
page = await fetchDoc<Page>({
slug,
collection: 'pages',
draft: isDraftMode,
})
} catch (error) {
// don't throw an error if the fetch fails
// this is so that we can render static fallback pages for the demo
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// in production you may want to redirect to a 404 page or at least log the error somewhere
}
if (!page) {
if (slug === 'home') page = staticHome
}
return generateMeta({ doc: page })
}

View File

@@ -1,24 +0,0 @@
@import '../../../_css/common';
.form {
margin-bottom: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
align-items: flex-start;
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.changePassword {
all: unset;
cursor: pointer;
text-decoration: underline;
}
.submit {
margin-top: calc(var(--base) / 2);
}

View File

@@ -1,161 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '../../../_components/Button'
import { Input } from '../../../_components/Input'
import { Message } from '../../../_components/Message'
import { useAuth } from '../../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
email: string
name: string
password: string
passwordConfirm: string
}
const AccountForm: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { setUser, user } = useAuth()
const [changePassword, setChangePassword] = useState(false)
const {
formState: { errors, isLoading },
handleSubmit,
register,
reset,
watch,
} = useForm<FormData>()
const password = useRef({})
password.current = watch('password', '')
const router = useRouter()
const onSubmit = useCallback(
async (data: FormData) => {
if (user) {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/${user.id}`, {
// Make sure to include cookies with fetch
body: JSON.stringify(data),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'PATCH',
})
if (response.ok) {
const json = await response.json()
setUser(json.doc)
setSuccess('Successfully updated account.')
setError('')
setChangePassword(false)
reset({
name: json.doc.name,
email: json.doc.email,
password: '',
passwordConfirm: '',
})
} else {
setError('There was a problem updating your account.')
}
}
},
[user, setUser, reset],
)
useEffect(() => {
if (user === null) {
router.push(
`/login?error=${encodeURIComponent(
'You must be logged in to view this page.',
)}&redirect=${encodeURIComponent('/account')}`,
)
}
// Once user is loaded, reset form to have default values
if (user) {
reset({
name: user.name,
email: user.email,
password: '',
passwordConfirm: '',
})
}
}, [user, router, reset, changePassword])
return (
<form className={classes.form} onSubmit={handleSubmit(onSubmit)}>
<Message className={classes.message} error={error} success={success} />
{!changePassword ? (
<Fragment>
<p>
{'Change your account details below, or '}
<button
className={classes.changePassword}
onClick={() => setChangePassword(!changePassword)}
type="button"
>
click here
</button>
{' to change your password.'}
</p>
<Input
error={errors.email}
label="Email Address"
name="email"
register={register}
required
type="email"
/>
<Input error={errors.name} label="Name" name="name" register={register} />
</Fragment>
) : (
<Fragment>
<p>
{'Change your password below, or '}
<button
className={classes.changePassword}
onClick={() => setChangePassword(!changePassword)}
type="button"
>
cancel
</button>
.
</p>
<Input
error={errors.password}
label="Password"
name="password"
register={register}
required
type="password"
/>
<Input
error={errors.passwordConfirm}
label="Confirm Password"
name="passwordConfirm"
register={register}
required
type="password"
validate={(value) => value === password.current || 'The passwords do not match'}
/>
</Fragment>
)}
<Button
appearance="primary"
className={classes.submit}
disabled={isLoading}
label={isLoading ? 'Processing' : changePassword ? 'Change Password' : 'Update Account'}
type="submit"
/>
</form>
)
}
export default AccountForm

View File

@@ -1,21 +0,0 @@
.account {
margin-bottom: var(--block-padding);
}
.params {
margin-top: var(--base);
}
.column {
display: flex;
flex-direction: column;
gap: var(--base);
}
.comment {
margin: 0;
}
.meta {
margin: 0;
}

View File

@@ -1,114 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React, { Fragment } from 'react'
import { fetchComments } from '../../_api/fetchComments'
import { Button } from '../../_components/Button'
import { Gutter } from '../../_components/Gutter'
import { HR } from '../../_components/HR'
import { RenderParams } from '../../_components/RenderParams'
import { LowImpactHero } from '../../_heros/LowImpact'
import { formatDateTime } from '../../_utilities/formatDateTime'
import { getMeUser } from '../../_utilities/getMeUser'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
import AccountForm from './AccountForm'
import classes from './index.module.scss'
export default async function Account() {
const { user } = await getMeUser({
nullUserRedirect: `/login?error=${encodeURIComponent(
'You must be logged in to access your account.',
)}&redirect=${encodeURIComponent('/account')}`,
})
const comments = await fetchComments({
user: user?.id,
})
return (
<Fragment>
<Gutter>
<RenderParams className={classes.params} />
</Gutter>
<LowImpactHero
media={null}
richText={[
{
type: 'h1',
children: [
{
text: 'Account',
},
],
},
{
type: 'paragraph',
children: [
{
text: 'This is your account dashboard. Here you can update your account information, view your comment history, and more. To manage all users, ',
},
{
type: 'link',
children: [
{
text: 'login to the admin dashboard.',
},
],
url: '/admin/collections/users',
},
],
},
]}
type="lowImpact"
/>
<Gutter className={classes.account}>
<AccountForm />
<HR />
<h2>Comments</h2>
<p>
These are the comments you have placed over time. Each comment is associated with a
specific post. All comments must be approved by an admin before they appear on the site.
</p>
<HR />
{comments?.length === 0 && <p>You have not made any comments yet.</p>}
{comments.length > 0 &&
comments?.map((com, index) => {
const { comment, createdAt, doc } = com
if (!comment) return null
return (
<Fragment key={index}>
<div className={classes.column}>
<p className={classes.comment}>"{comment}"</p>
<p className={classes.meta}>
{'Posted '}
{doc && typeof doc === 'object' && (
<Fragment>
{' to '}
<Link href={`/posts/${doc?.slug}`}>{doc?.title || 'Untitled Post'}</Link>
</Fragment>
)}
{createdAt && ` on ${formatDateTime(createdAt)}`}
</p>
</div>
{index < comments.length - 1 && <HR />}
</Fragment>
)
})}
<HR />
<Button appearance="secondary" href="/logout" label="Log out" />
</Gutter>
</Fragment>
)
}
export const metadata: Metadata = {
description: 'Create an account or log in to your existing account.',
openGraph: mergeOpenGraph({
title: 'Account',
url: '/account',
}),
title: 'Account',
}

View File

@@ -1,22 +0,0 @@
@import '../../../_css/common';
.form {
margin-bottom: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
align-items: flex-start;
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: calc(var(--base) / 2);
}
.message {
margin-bottom: var(--base);
}

View File

@@ -1,85 +0,0 @@
'use client'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '../../../_components/Button'
import { Input } from '../../../_components/Input'
import { Message } from '../../../_components/Message'
import { useAuth } from '../../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
email: string
password: string
}
const LoginForm: React.FC = () => {
const searchParams = useSearchParams()
const allParams = searchParams.toString() ? `?${searchParams.toString()}` : ''
const redirect = useRef(searchParams.get('redirect'))
const { login } = useAuth()
const router = useRouter()
const [error, setError] = React.useState<null | string>(null)
const {
formState: { errors, isLoading },
handleSubmit,
register,
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
try {
await login(data)
if (redirect?.current) router.push(redirect.current)
else router.push('/account')
} catch (_) {
setError('There was an error with the credentials provided. Please try again.')
}
},
[login, router],
)
return (
<form className={classes.form} onSubmit={handleSubmit(onSubmit)}>
<p>
{`This is where your users will login to manage their account, view their comment history, and more. To manage all users, `}
<Link href="/admin/collections/users">login to the admin dashboard</Link>.
</p>
<Message className={classes.message} error={error} />
<Input
error={errors.email}
label="Email Address"
name="email"
register={register}
required
type="email"
/>
<Input
error={errors.password}
label="Password"
name="password"
register={register}
required
type="password"
/>
<Button
appearance="primary"
className={classes.submit}
disabled={isLoading}
label={isLoading ? 'Processing' : 'Login'}
type="submit"
/>
<div>
<Link href={`/create-account${allParams}`}>Create an account</Link>
<br />
<Link href={`/recover-password${allParams}`}>Recover your password</Link>
</div>
</form>
)
}
export default LoginForm

View File

@@ -1,9 +0,0 @@
@import '../../_css/common';
.login {
margin-bottom: var(--block-padding);
}
.params {
margin-top: var(--base);
}

View File

@@ -1,33 +0,0 @@
import type { Metadata } from 'next'
import React from 'react'
import { Gutter } from '../../_components/Gutter'
import { RenderParams } from '../../_components/RenderParams'
import { getMeUser } from '../../_utilities/getMeUser'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
import LoginForm from './LoginForm'
import classes from './index.module.scss'
export default async function Login() {
await getMeUser({
validUserRedirect: `/account?warning=${encodeURIComponent('You are already logged in.')}`,
})
return (
<Gutter className={classes.login}>
<RenderParams className={classes.params} />
<h1>Log in</h1>
<LoginForm />
</Gutter>
)
}
export const metadata: Metadata = {
description: 'Login or create an account to get started.',
openGraph: mergeOpenGraph({
title: 'Login',
url: '/login',
}),
title: 'Login',
}

View File

@@ -1,61 +0,0 @@
'use client'
import Link from 'next/link'
import React, { Fragment, useEffect, useState } from 'react'
import type { Settings } from '../../../../payload/payload-types'
import { useAuth } from '../../../_providers/Auth'
export const LogoutPage: React.FC<{
settings: Settings
}> = (props) => {
const { settings } = props
const { postsPage, projectsPage } = settings || {}
const { logout } = useAuth()
const [success, setSuccess] = useState('')
const [error, setError] = useState('')
useEffect(() => {
const performLogout = async () => {
try {
await logout()
setSuccess('Logged out successfully.')
} catch (_) {
setError('You are already logged out.')
}
}
performLogout()
}, [logout])
const hasPostsPage = typeof postsPage === 'object' && postsPage?.slug
const hasProjectsPage = typeof projectsPage === 'object' && projectsPage?.slug
return (
<Fragment>
{(error || success) && (
<div>
<h1>{error || success}</h1>
<p>
{'What would you like to do next? '}
{hasPostsPage && hasProjectsPage && <Fragment>{'Browse '}</Fragment>}
{hasPostsPage && (
<Fragment>
<Link href={`/${postsPage.slug}`}>all posts</Link>
</Fragment>
)}
{hasPostsPage && hasProjectsPage && <Fragment>{' or '}</Fragment>}
{hasProjectsPage && (
<Fragment>
<Link href={`/${projectsPage.slug}`}>all projects</Link>
</Fragment>
)}
{` To log back in, `}
<Link href="/login">click here</Link>.
</p>
</div>
)}
</Fragment>
)
}

View File

@@ -1,3 +0,0 @@
.logout {
margin-bottom: var(--block-padding);
}

View File

@@ -1,39 +0,0 @@
import type { Metadata } from 'next'
import React from 'react'
import type { Settings } from '../../../payload/payload-types'
import { fetchSettings } from '../../_api/fetchGlobals'
import { Gutter } from '../../_components/Gutter'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
import { LogoutPage } from './LogoutPage'
import classes from './index.module.scss'
export default async function Logout() {
let settings: Settings | null = null
try {
settings = await fetchSettings()
} catch (error) {
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// so swallow the error here and simply render the page with fallback data where necessary
// in production you may want to redirect to a 404 page or at least log the error somewhere
// console.error(error)
}
return (
<Gutter className={classes.logout}>
<LogoutPage settings={settings} />
</Gutter>
)
}
export const metadata: Metadata = {
description: 'You have been logged out.',
openGraph: mergeOpenGraph({
title: 'Logout',
url: '/logout',
}),
title: 'Logout',
}

View File

@@ -1,15 +0,0 @@
import { Button } from '../_components/Button'
import { Gutter } from '../_components/Gutter'
import { VerticalPadding } from '../_components/VerticalPadding'
export default function NotFound() {
return (
<Gutter>
<VerticalPadding bottom="large" top="none">
<h1 style={{ marginBottom: 0 }}>404</h1>
<p>This page could not be found.</p>
<Button appearance="primary" href="/" label="Go Home" />
</VerticalPadding>
</Gutter>
)
}

View File

@@ -1,157 +0,0 @@
import type { Metadata } from 'next'
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import React from 'react'
import type { Post } from '../../../../payload/payload-types'
import { Comment } from '../../../../payload/payload-types'
import { fetchComments } from '../../../_api/fetchComments'
import { fetchDoc } from '../../../_api/fetchDoc'
import { fetchDocs } from '../../../_api/fetchDocs'
import { Blocks } from '../../../_components/Blocks'
import { PremiumContent } from '../../../_components/PremiumContent'
import { PostHero } from '../../../_heros/PostHero'
import { generateMeta } from '../../../_utilities/generateMeta'
// Force this page to be dynamic so that Next.js does not cache it
// See the note in '../../../[slug]/page.tsx' about this
export const dynamic = 'force-dynamic'
export default async function Post({ params: { slug } }) {
const { isEnabled: isDraftMode } = draftMode()
let post: Post | null = null
try {
post = await fetchDoc<Post>({
slug,
collection: 'posts',
draft: isDraftMode,
})
} catch (error) {
console.error(error) // eslint-disable-line no-console
}
if (!post) {
notFound()
}
const comments = await fetchComments({
doc: post?.id,
})
const { enablePremiumContent, layout, premiumContent, relatedPosts } = post
return (
<React.Fragment>
<PostHero post={post} />
<Blocks blocks={layout} />
{enablePremiumContent && <PremiumContent disableTopPadding postSlug={slug as string} />}
<Blocks
blocks={[
{
blockName: 'Comments',
blockType: 'comments',
comments,
doc: post,
introContent: [
{
type: 'h4',
children: [
{
text: 'Comments',
},
],
},
{
type: 'p',
children: [
{
text: 'Authenticated users can leave comments on this post. All new comments are given the status "draft" until they are approved by an admin. Draft comments are not accessible to the public and will not show up on this page until it is marked as "published". To manage all comments, ',
},
{
type: 'link',
children: [
{
text: 'navigate to the admin dashboard',
},
],
url: '/admin/collections/comments',
},
{
text: '.',
},
],
},
],
relationTo: 'posts',
},
{
blockName: 'Related Posts',
blockType: 'relatedPosts',
docs: relatedPosts,
introContent: [
{
type: 'h4',
children: [
{
text: 'Related posts',
},
],
},
{
type: 'p',
children: [
{
text: 'The posts displayed here are individually selected for this page. Admins can select any number of related posts to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate posts by category complete with pagination. To manage related posts, ',
},
{
type: 'link',
children: [
{
text: 'navigate to the admin dashboard',
},
],
url: `/admin/collections/posts/${post.id}`,
},
{
text: '.',
},
],
},
],
relationTo: 'posts',
},
]}
disableTopPadding
/>
</React.Fragment>
)
}
export async function generateStaticParams() {
try {
const posts = await fetchDocs<Post>('posts')
return posts?.map(({ slug }) => slug)
} catch (error) {
return []
}
}
export async function generateMetadata({ params: { slug } }): Promise<Metadata> {
const { isEnabled: isDraftMode } = draftMode()
let post: Post | null = null
try {
post = await fetchDoc<Post>({
slug,
collection: 'posts',
draft: isDraftMode,
})
} catch (error) {}
return generateMeta({ doc: post })
}

View File

@@ -1,112 +0,0 @@
import type { Metadata } from 'next'
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import React from 'react'
import type { Project } from '../../../../payload/payload-types'
import { fetchDoc } from '../../../_api/fetchDoc'
import { fetchDocs } from '../../../_api/fetchDocs'
import { RelatedPosts } from '../../../_blocks/RelatedPosts'
import { Blocks } from '../../../_components/Blocks'
import { ProjectHero } from '../../../_heros/ProjectHero'
import { generateMeta } from '../../../_utilities/generateMeta'
// Force this page to be dynamic so that Next.js does not cache it
// See the note in '../../../[slug]/page.tsx' about this
export const dynamic = 'force-dynamic'
export default async function Project({ params: { slug } }) {
const { isEnabled: isDraftMode } = draftMode()
let project: Project | null = null
try {
project = await fetchDoc<Project>({
slug,
collection: 'projects',
draft: isDraftMode,
})
} catch (error) {
console.error(error) // eslint-disable-line no-console
}
if (!project) {
notFound()
}
const { layout, relatedProjects } = project
return (
<React.Fragment>
<ProjectHero project={project} />
<Blocks
blocks={[
...layout,
{
blockName: 'Related Projects',
blockType: 'relatedPosts',
docs: relatedProjects,
introContent: [
{
type: 'h4',
children: [
{
text: 'Related projects',
},
],
},
{
type: 'p',
children: [
{
text: 'The projects displayed here are individually selected for this page. Admins can select any number of related projects to display here and the layout will adjust accordingly. Alternatively, you could swap this out for the "Archive" block to automatically populate projects by category complete with pagination. To manage related projects, ',
},
{
type: 'link',
children: [
{
text: 'navigate to the admin dashboard',
},
],
url: `/admin/collections/projects/${project.id}`,
},
{
text: '.',
},
],
},
],
relationTo: 'projects',
},
]}
/>
</React.Fragment>
)
}
export async function generateStaticParams() {
try {
const projects = await fetchDocs<Project>('projects')
return projects?.map(({ slug }) => slug)
} catch (error) {
return []
}
}
export async function generateMetadata({ params: { slug } }): Promise<Metadata> {
const { isEnabled: isDraftMode } = draftMode()
let project: Project | null = null
try {
project = await fetchDoc<Project>({
slug,
collection: 'projects',
draft: isDraftMode,
})
} catch (error) {}
return generateMeta({ doc: project })
}

View File

@@ -1,22 +0,0 @@
@import '../../../_css/common';
.error {
color: red;
margin-bottom: 15px;
}
.formWrapper {
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: var(--base);
}
.message {
margin-bottom: var(--base);
}

View File

@@ -1,87 +0,0 @@
'use client'
import Link from 'next/link'
import React, { Fragment, useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '../../../_components/Button'
import { Input } from '../../../_components/Input'
import { Message } from '../../../_components/Message'
import classes from './index.module.scss'
type FormData = {
email: string
}
export const RecoverPasswordForm: React.FC = () => {
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const {
formState: { errors },
handleSubmit,
register,
} = useForm<FormData>()
const onSubmit = useCallback(async (data: FormData) => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/forgot-password`,
{
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
},
)
if (response.ok) {
setSuccess(true)
setError('')
} else {
setError(
'There was a problem while attempting to send you a password reset email. Please try again.',
)
}
}, [])
return (
<Fragment>
{!success && (
<React.Fragment>
<h1>Recover Password</h1>
<div className={classes.formWrapper}>
<p>
{`Please enter your email below. You will receive an email message with instructions on
how to reset your password. To manage your all users, `}
<Link href="/admin/collections/users">login to the admin dashboard</Link>.
</p>
<form className={classes.form} onSubmit={handleSubmit(onSubmit)}>
<Message className={classes.message} error={error} />
<Input
error={errors.email}
label="Email Address"
name="email"
register={register}
required
type="email"
/>
<Button
appearance="primary"
className={classes.submit}
label="Recover Password"
type="submit"
/>
</form>
</div>
</React.Fragment>
)}
{success && (
<React.Fragment>
<h1>Request submitted</h1>
<p>Check your email for a link that will allow you to securely reset your password.</p>
</React.Fragment>
)}
</Fragment>
)
}

View File

@@ -1,5 +0,0 @@
@import '../../_css/common';
.recoverPassword {
margin-bottom: var(--block-padding);
}

View File

@@ -1,25 +0,0 @@
import type { Metadata } from 'next'
import React from 'react'
import { Gutter } from '../../_components/Gutter'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
import { RecoverPasswordForm } from './RecoverPasswordForm'
import classes from './index.module.scss'
export default async function RecoverPassword() {
return (
<Gutter className={classes.recoverPassword}>
<RecoverPasswordForm />
</Gutter>
)
}
export const metadata: Metadata = {
description: 'Enter your email address to recover your password.',
openGraph: mergeOpenGraph({
title: 'Recover Password',
url: '/recover-password',
}),
title: 'Recover Password',
}

View File

@@ -1,13 +0,0 @@
@import '../../../_css/common';
.form {
width: 66.66%;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: var(--base);
}

View File

@@ -1,86 +0,0 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '../../../_components/Button'
import { Input } from '../../../_components/Input'
import { Message } from '../../../_components/Message'
import { useAuth } from '../../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
password: string
token: string
}
export const ResetPasswordForm: React.FC = () => {
const [error, setError] = useState('')
const { login } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const {
formState: { errors },
handleSubmit,
register,
reset,
} = useForm<FormData>()
const onSubmit = useCallback(
async (data: FormData) => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/reset-password`,
{
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
},
)
if (response.ok) {
const json = await response.json()
// Automatically log the user in after they successfully reset password
await login({ email: json.user.email, password: data.password })
// Redirect them to `/account` with success message in URL
router.push('/account?success=Password reset successfully.')
} else {
setError('There was a problem while resetting your password. Please try again later.')
}
},
[router, login],
)
// when Next.js populates token within router,
// reset form with new token value
useEffect(() => {
reset({ token: token || undefined })
}, [reset, token])
return (
<form className={classes.form} onSubmit={handleSubmit(onSubmit)}>
<Message className={classes.message} error={error} />
<Input
error={errors.password}
label="New Password"
name="password"
register={register}
required
type="password"
/>
<input type="hidden" {...register('token')} />
<Button
appearance="primary"
className={classes.submit}
label="Reset Password"
type="submit"
/>
</form>
)
}

View File

@@ -1,3 +0,0 @@
.resetPassword {
margin-bottom: var(--block-padding);
}

View File

@@ -1,27 +0,0 @@
import type { Metadata } from 'next'
import React from 'react'
import { Gutter } from '../../_components/Gutter'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
import { ResetPasswordForm } from './ResetPasswordForm'
import classes from './index.module.scss'
export default async function ResetPassword() {
return (
<Gutter className={classes.resetPassword}>
<h1>Reset Password</h1>
<p>Please enter a new password below.</p>
<ResetPasswordForm />
</Gutter>
)
}
export const metadata: Metadata = {
description: 'Enter a new password.',
openGraph: mergeOpenGraph({
title: 'Reset Password',
url: '/reset-password',
}),
title: 'Reset Password',
}

View File

@@ -1,43 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React, { Fragment } from 'react'
import { CallToActionBlock } from '../../../_blocks/CallToAction'
import { Button } from '../../../_components/Button'
import { Gutter } from '../../../_components/Gutter'
import { VerticalPadding } from '../../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../../_utilities/mergeOpenGraph'
export default async function ButtonsPage() {
return (
<Fragment>
<Gutter>
<p>
<Link href="/styleguide">Styleguide</Link>
{' / '}
<span>Buttons</span>
</p>
<h1>Buttons</h1>
</Gutter>
<Gutter>
<VerticalPadding bottom="large" top="none">
<Button appearance="default" label="Default Button" />
<br /> <br />
<Button appearance="primary" label="Primary Button" />
<br /> <br />
<Button appearance="secondary" label="Secondary Button" />
</VerticalPadding>
</Gutter>
</Fragment>
)
}
export const metadata: Metadata = {
description: 'Styleguide for Buttons',
openGraph: mergeOpenGraph({
title: 'Buttons',
url: '/styleguide/buttons',
}),
title: 'Buttons',
}

View File

@@ -1,100 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React, { Fragment } from 'react'
import { CallToActionBlock } from '../../../_blocks/CallToAction'
import { Gutter } from '../../../_components/Gutter'
import { VerticalPadding } from '../../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../../_utilities/mergeOpenGraph'
export default async function CallToActionPage() {
return (
<Fragment>
<Gutter>
<p>
<Link href="/styleguide">Styleguide</Link>
{' / '}
<span>Call To Action Block</span>
</p>
<h1>Call To Action Block</h1>
</Gutter>
<VerticalPadding bottom="large" top="none">
<CallToActionBlock
blockType="cta"
links={[
{
link: {
type: 'custom',
appearance: 'primary',
label: 'Lorem ipsum dolor sit amet',
reference: null,
url: '#',
},
},
]}
richText={[
{
type: 'h4',
children: [
{
text: 'Lorem ipsum dolor sit amet',
},
],
},
{
children: [
{
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
],
},
]}
/>
<br />
<br />
<CallToActionBlock
blockType="cta"
invertBackground
links={[
{
link: {
type: 'custom',
appearance: 'primary',
label: 'Lorem ipsum dolor sit amet',
reference: null,
url: '#',
},
},
]}
richText={[
{
type: 'h4',
children: [
{
text: 'Lorem ipsum dolor sit amet',
},
],
},
{
children: [
{
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
],
},
]}
/>
</VerticalPadding>
</Fragment>
)
}
export const metadata: Metadata = {
description: 'Styleguide for the Call To Action Block',
openGraph: mergeOpenGraph({
title: 'Call To Action Block',
url: '/styleguide/call-to-action',
}),
title: 'Call To Action Block',
}

View File

@@ -1,48 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React, { Fragment } from 'react'
import { ContentBlock } from '../../../_blocks/Content'
import { Gutter } from '../../../_components/Gutter'
import { VerticalPadding } from '../../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../../_utilities/mergeOpenGraph'
export default async function ContentBlockPage() {
return (
<Fragment>
<Gutter>
<p>
<Link href="/styleguide">Styleguide</Link>
{' / '}
<span>Content Block</span>
</p>
<h1>Content Block</h1>
</Gutter>
<VerticalPadding bottom="large" top="none">
<ContentBlock
blockType="content"
columns={[
{
richText: [
{
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
],
size: 'full',
},
]}
/>
</VerticalPadding>
</Fragment>
)
}
export const metadata: Metadata = {
description: 'Styleguide for the Content Block',
openGraph: mergeOpenGraph({
title: 'Content Block',
url: '/styleguide/content-block',
}),
title: 'Content Block',
}

View File

@@ -1,45 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React, { Fragment } from 'react'
import staticImage from '../../../../../public/static-image.jpg'
import { MediaBlock } from '../../../_blocks/MediaBlock'
import { Gutter } from '../../../_components/Gutter'
import { VerticalPadding } from '../../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../../_utilities/mergeOpenGraph'
export default async function MediaBlockPage() {
return (
<Fragment>
<Gutter>
<p>
<Link href="/styleguide">Styleguide</Link>
{' / '}
<span>Media Block</span>
</p>
<h1>Media Block</h1>
</Gutter>
<VerticalPadding bottom="large" top="none">
<MediaBlock blockType="mediaBlock" media="" position="default" staticImage={staticImage} />
<br />
<br />
<MediaBlock
blockType="mediaBlock"
media=""
position="fullscreen"
staticImage={staticImage}
/>
</VerticalPadding>
</Fragment>
)
}
export const metadata: Metadata = {
description: 'Styleguide for media block.',
openGraph: mergeOpenGraph({
title: 'Media Block',
url: '/styleguide/media-block',
}),
title: 'Media Block',
}

View File

@@ -1,44 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React, { Fragment } from 'react'
import { Gutter } from '../../../_components/Gutter'
import { Message } from '../../../_components/Message'
import { VerticalPadding } from '../../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../../_utilities/mergeOpenGraph'
export default async function MessageComponentPage() {
return (
<Fragment>
<Gutter>
<p>
<Link href="/styleguide">Styleguide</Link>
{' / '}
<span>Message Component</span>
</p>
<h1>Message Component</h1>
</Gutter>
<Gutter>
<VerticalPadding bottom="large" top="none">
<Message message="This is a message" />
<br />
<Message error="This is an error" />
<br />
<Message success="This is a success" />
<br />
<Message warning="This is a warning" />
</VerticalPadding>
</Gutter>
</Fragment>
)
}
export const metadata: Metadata = {
description: 'Styleguide for message component.',
openGraph: mergeOpenGraph({
title: 'Message Component',
url: '/styleguide/message',
}),
title: 'Message Component',
}

View File

@@ -1,40 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React from 'react'
import { Gutter } from '../../_components/Gutter'
import { VerticalPadding } from '../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../_utilities/mergeOpenGraph'
export default async function Typography() {
return (
<Gutter>
<VerticalPadding bottom="large" top="none">
<h1>Styleguide</h1>
<Link href="/styleguide/typography">Typography</Link>
<br />
<h2>Blocks</h2>
<Link href="/styleguide/content-block">Content Block</Link>
<br />
<Link href="/styleguide/media-block">Media Block</Link>
<br />
<Link href="/styleguide/call-to-action">Call To Action Block</Link>
<br />
<h2>Components</h2>
<Link href="/styleguide/buttons">Buttons</Link>
<br />
<Link href="/styleguide/message">Message</Link>
</VerticalPadding>
</Gutter>
)
}
export const metadata: Metadata = {
description: 'Styleguide',
openGraph: mergeOpenGraph({
title: 'Styleguide',
url: '/styleguide',
}),
title: 'Styleguide',
}

View File

@@ -1,55 +0,0 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import React from 'react'
import { Gutter } from '../../../_components/Gutter'
import { VerticalPadding } from '../../../_components/VerticalPadding'
import { mergeOpenGraph } from '../../../_utilities/mergeOpenGraph'
export default async function Typography() {
return (
<Gutter>
<p>
<Link href="/styleguide">Styleguide</Link>
{' / '}
<span>Typography</span>
</p>
<VerticalPadding bottom="large" top="none">
<h1>Typography</h1>
<h1>H1: Lorem ipsum dolor sit amet officia deserunt.</h1>
<h2>H2: Lorem ipsum dolor sit amet in culpa qui officia deserunt consectetur.</h2>
<h3>
H3: Lorem ipsum dolor sit amet in culpa qui officia deserunt consectetur adipiscing elit.
</h3>
<h4>
H4: Lorem ipsum dolor sit amet, consectetur adipiscing elit lorem ipsum dolor sit amet,
consectetur adipiscing elit.
</h4>
<h5>
H5: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.
</h5>
<h6>
H6: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.
</h6>
<p>
P: Lorem ipsum dolor sit amet, consectetur adipiscing elit consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. dolore magna aliqua.
Quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Lorem
ipsum doalor sit amet in culpa qui officia deserunt consectetur adipiscing elit.
</p>
</VerticalPadding>
</Gutter>
)
}
export const metadata: Metadata = {
description: 'Styleguide for typography.',
openGraph: mergeOpenGraph({
title: 'Typography',
url: '/styleguide/typography',
}),
title: 'Typography',
}

View File

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

View File

@@ -0,0 +1,9 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY it because it could be re-written at any time. */
import config from '@payload-config'
import { GRAPHQL_POST } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)

View File

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

View File

@@ -1,34 +0,0 @@
import type { Comment, Post, User } from '../../payload/payload-types'
import { COMMENTS_BY_DOC, COMMENTS_BY_USER } from '../_graphql/comments'
import { GRAPHQL_API_URL } from './shared'
export const fetchComments = async (args: {
doc?: Post['id']
user?: User['id']
}): Promise<Comment[]> => {
const { doc, user } = args || {}
const docs: Comment[] = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: user ? COMMENTS_BY_USER : COMMENTS_BY_DOC,
variables: {
doc,
user,
},
}),
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
?.then((res) => res.json())
?.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching docs')
return res?.data?.Comments?.docs
})
return docs
}

View File

@@ -1,66 +0,0 @@
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'
import type { Config } from '../../payload/payload-types'
import { PAGE } from '../_graphql/pages'
import { POST } from '../_graphql/posts'
import { PROJECT } from '../_graphql/projects'
import { GRAPHQL_API_URL } from './shared'
import { payloadToken } from './token'
const queryMap = {
pages: {
key: 'Pages',
query: PAGE,
},
posts: {
key: 'Posts',
query: POST,
},
projects: {
key: 'Projects',
query: PROJECT,
},
}
export const fetchDoc = async <T>(args: {
collection: keyof Config['collections']
draft?: boolean
id?: string
slug?: string
}): Promise<T> => {
const { slug, collection, draft } = args || {}
if (!queryMap[collection]) throw new Error(`Collection ${collection} not found`)
let token: RequestCookie | undefined
if (draft) {
const { cookies } = await import('next/headers')
token = cookies().get(payloadToken)
}
const doc: T = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: queryMap[collection].query,
variables: {
slug,
draft,
},
}),
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...(token?.value && draft ? { Authorization: `JWT ${token.value}` } : {}),
},
method: 'POST',
next: { tags: [`${collection}_${slug}`] },
})
?.then((res) => res.json())
?.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching doc')
return res?.data?.[queryMap[collection].key]?.docs?.[0]
})
return doc
}

View File

@@ -1,61 +0,0 @@
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'
import type { Config } from '../../payload/payload-types'
import { PAGES } from '../_graphql/pages'
import { POSTS } from '../_graphql/posts'
import { PROJECTS } from '../_graphql/projects'
import { GRAPHQL_API_URL } from './shared'
import { payloadToken } from './token'
const queryMap = {
pages: {
key: 'Pages',
query: PAGES,
},
posts: {
key: 'Posts',
query: POSTS,
},
projects: {
key: 'Projects',
query: PROJECTS,
},
}
export const fetchDocs = async <T>(
collection: keyof Config['collections'],
draft?: boolean,
variables?: Record<string, unknown>,
): Promise<T[]> => {
if (!queryMap[collection]) throw new Error(`Collection ${collection} not found`)
let token: RequestCookie | undefined
if (draft) {
const { cookies } = await import('next/headers')
token = cookies().get(payloadToken)
}
const docs: T[] = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: queryMap[collection].query,
variables,
}),
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
...(token?.value && draft ? { Authorization: `JWT ${token.value}` } : {}),
},
method: 'POST',
next: { tags: [collection] },
})
?.then((res) => res.json())
?.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching docs')
return res?.data?.[queryMap[collection].key]?.docs
})
return docs
}

View File

@@ -1,103 +0,0 @@
import type { Footer, Header, Settings } from '../../payload/payload-types'
import { FOOTER_QUERY, HEADER_QUERY, SETTINGS_QUERY } from '../_graphql/globals'
import { GRAPHQL_API_URL } from './shared'
export async function fetchSettings(): Promise<Settings> {
if (!GRAPHQL_API_URL) throw new Error('NEXT_PUBLIC_SERVER_URL not found')
const settings = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: SETTINGS_QUERY,
}),
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
?.then((res) => {
if (!res.ok) throw new Error('Error fetching doc')
return res.json()
})
?.then((res) => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching settings')
return res.data?.Settings
})
return settings
}
export async function fetchHeader(): Promise<Header> {
if (!GRAPHQL_API_URL) throw new Error('NEXT_PUBLIC_SERVER_URL not found')
const header = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: HEADER_QUERY,
}),
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
?.then((res) => {
if (!res.ok) throw new Error('Error fetching doc')
return res.json()
})
?.then((res) => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching header')
return res.data?.Header
})
return header
}
export async function fetchFooter(): Promise<Footer> {
if (!GRAPHQL_API_URL) throw new Error('NEXT_PUBLIC_SERVER_URL not found')
const footer = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: FOOTER_QUERY,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
.then((res) => {
if (!res.ok) throw new Error('Error fetching doc')
return res.json()
})
?.then((res) => {
if (res?.errors) throw new Error(res?.errors[0]?.message || 'Error fetching footer')
return res.data?.Footer
})
return footer
}
export const fetchGlobals = async (): Promise<{
footer: Footer
header: Header
settings: Settings
}> => {
// initiate requests in parallel, then wait for them to resolve
// this will eagerly start to the fetch requests at the same time
// see https://nextjs.org/docs/app/building-your-application/data-fetching/fetching
const settingsData = fetchSettings()
const headerData = fetchHeader()
const footerData = fetchFooter()
const [settings, header, footer]: [Settings, Header, Footer] = await Promise.all([
await settingsData,
await headerData,
await footerData,
])
return {
footer,
header,
settings,
}
}

View File

@@ -1,50 +0,0 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import type { User } from '../../payload/payload-types'
import { ME_QUERY } from '../_graphql/me'
import { GRAPHQL_API_URL } from './shared'
export const getMe = async (args?: {
nullUserRedirect?: string
userRedirect?: string
}): Promise<{
token: string
user: User
}> => {
const { nullUserRedirect, userRedirect } = args || {}
const cookieStore = cookies()
const token = cookieStore.get('payload-token')?.value
const meUserReq = await fetch(`${GRAPHQL_API_URL}/api/graphql`, {
body: JSON.stringify({
query: ME_QUERY,
}),
cache: 'no-store',
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
const {
user,
}: {
user: User
} = await meUserReq.json()
if (userRedirect && meUserReq.ok && user) {
redirect(userRedirect)
}
if (nullUserRedirect && (!meUserReq.ok || !user)) {
redirect(nullUserRedirect)
}
return {
token,
user,
}
}

View File

@@ -1,3 +0,0 @@
export const GRAPHQL_API_URL = process.env.NEXT_BUILD
? `http://127.0.0.1:${process.env.PORT || 3000}`
: process.env.NEXT_PUBLIC_SERVER_URL

View File

@@ -1 +0,0 @@
export const payloadToken = 'payload-token'

View File

@@ -1,13 +0,0 @@
@import '../../_css/common';
.archiveBlock {
position: relative;
}
.introContent {
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: calc(var(--base) * 2);
}
}

View File

@@ -1,46 +0,0 @@
import React from 'react'
import type { ArchiveBlockProps } from './types'
import { CollectionArchive } from '../../_components/CollectionArchive'
import { Gutter } from '../../_components/Gutter'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
export const ArchiveBlock: React.FC<
ArchiveBlockProps & {
id?: string
}
> = (props) => {
const {
id,
categories,
introContent,
limit,
populateBy,
populatedDocs,
populatedDocsTotal,
relationTo,
selectedDocs,
} = props
return (
<div className={classes.archiveBlock} id={`block-${id}`}>
{introContent && (
<Gutter className={classes.introContent}>
<RichText content={introContent} />
</Gutter>
)}
<CollectionArchive
categories={categories}
limit={limit}
populateBy={populateBy}
populatedDocs={populatedDocs}
populatedDocsTotal={populatedDocsTotal}
relationTo={relationTo}
selectedDocs={selectedDocs}
sort="-publishedAt"
/>
</div>
)
}

View File

@@ -1,61 +0,0 @@
@use '../../_css/queries.scss' as *;
$spacer-h: calc(var(--block-padding) / 2);
.callToAction {
padding-left: $spacer-h;
padding-right: $spacer-h;
position: relative;
background-color: var(--theme-elevation-100);
color: var(--theme-elevation-1000);
}
:global([data-theme='dark']) {
.callToAction {
background-color: var(--theme-elevation-0);
color: var(--theme-elevation-1000);
}
}
.invert {
background-color: var(--theme-elevation-1000);
color: var(--theme-elevation-0);
}
:global([data-theme='dark']) {
.invert {
background-color: var(--theme-elevation-900);
color: var(--theme-elevation-0);
}
}
.wrap {
display: flex;
gap: $spacer-h;
align-items: center;
@include mid-break {
flex-direction: column;
align-items: flex-start;
}
}
.content {
flex-grow: 1;
}
.linkGroup {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
flex-shrink: 0;
> * {
margin-bottom: calc(var(--base) / 2);
&:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -1,38 +0,0 @@
import React from 'react'
import type { Page } from '../../../payload/payload-types'
import { Gutter } from '../../_components/Gutter'
import { CMSLink } from '../../_components/Link'
import RichText from '../../_components/RichText'
import { VerticalPadding } from '../../_components/VerticalPadding'
import classes from './index.module.scss'
type Props = Extract<Page['layout'][0], { blockType: 'cta' }>
export const CallToActionBlock: React.FC<
Props & {
id?: string
}
> = ({ invertBackground, links, richText }) => {
return (
<Gutter>
<VerticalPadding
className={[classes.callToAction, invertBackground && classes.invert]
.filter(Boolean)
.join(' ')}
>
<div className={classes.wrap}>
<div className={classes.content}>
<RichText className={classes.richText} content={richText} />
</div>
<div className={classes.linkGroup}>
{(links || []).map(({ link }, i) => {
return <CMSLink key={i} {...link} invert={invertBackground} />
})}
</div>
</div>
</VerticalPadding>
</Gutter>
)
}

View File

@@ -1,21 +0,0 @@
@import '../../../_css/common';
.form {
margin-bottom: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
align-items: flex-start;
@include mid-break {
width: 100%;
}
}
.submit {
margin-top: calc(var(--base) / 2);
}
.message {
margin-bottom: var(--base);
}

View File

@@ -1,124 +0,0 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import React, { Fragment, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import type { Comment } from '../../../../payload/payload-types'
import { Button } from '../../../_components/Button'
import { Input } from '../../../_components/Input'
import { Message } from '../../../_components/Message'
import { useAuth } from '../../../_providers/Auth'
import classes from './index.module.scss'
type FormData = {
comment: string
}
export const CommentForm: React.FC<{
docID: string
}> = ({ docID }) => {
const pathname = usePathname()
const [error, setError] = React.useState<null | string>(null)
const [success, setSuccess] = React.useState<React.ReactNode | null>(null)
const {
formState: { errors, isLoading },
handleSubmit,
register,
reset,
} = useForm<FormData>()
const { user } = useAuth()
const onSubmit = useCallback(
async (data: FormData) => {
if (!user) return
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/comments`, {
body: JSON.stringify({
// All comments are created as drafts so that they can be moderated before being published
// Navigate to the admin dashboard and change the comment status to "published" for it to appear on the site
doc: docID,
status: 'draft',
user: user.id,
...data,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
const json: Comment & {
message?: string
} = await res.json()
if (!res.ok) throw new Error(json.message)
setError(null)
setSuccess(
<Fragment>
{'Your comment was submitted for moderation successfully. To approve it, '}
<Link
href={`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/comments/${
typeof json.doc === 'object' ? json.doc.id : json.doc
}`}
>
navigate to the admin dashboard
</Link>
{' and click "publish".'}
</Fragment>,
)
reset()
} catch (_) {
setError('Something went wrong')
}
},
[docID, user, reset],
)
return (
<form className={classes.form} onSubmit={handleSubmit(onSubmit)}>
<Message className={classes.message} error={error} success={success} />
<Input
disabled={!user}
error={errors.comment}
label="Comment"
name="comment"
placeholder={user ? 'Leave a comment' : 'Login to leave a comment'}
register={register}
required
type="textarea"
validate={(value) => {
if (!value) return 'Please enter a comment'
if (value.length < 3) return 'Please enter a comment over 3 characters'
if (value.length > 500) return 'Please enter a comment under 500 characters'
return true
}}
/>
{!user ? (
<Button
appearance="primary"
className={classes.submit}
disabled={isLoading}
href={`/login?redirect=${encodeURIComponent(pathname)}`}
label="Login to comment"
/>
) : (
<Button
appearance="primary"
className={classes.submit}
disabled={isLoading}
label={isLoading ? 'Processing' : 'Comment'}
type="submit"
/>
)}
</form>
)
}

View File

@@ -1,28 +0,0 @@
@import '../../_css/common';
.introContent {
position: relative;
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: var(--base);
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--base);
}
.draft {
margin: 0;
}
.comment {
margin: 0;
}
.meta {
margin: 0;
}

View File

@@ -1,89 +0,0 @@
'use client'
import Link from 'next/link'
import React, { Fragment } from 'react'
import type { Comment, Post, Project } from '../../../payload/payload-types'
import { Gutter } from '../../_components/Gutter'
import { HR } from '../../_components/HR'
import { Message } from '../../_components/Message'
import RichText from '../../_components/RichText'
import { formatDateTime } from '../../_utilities/formatDateTime'
import { CommentForm } from './CommentForm'
import classes from './index.module.scss'
export type CommentsBlockProps = {
blockName: string
blockType: 'comments'
comments: Comment[]
doc: Post | Project
introContent?: any
relationTo: 'posts' | 'projects'
}
export const CommentsBlock: React.FC<CommentsBlockProps> = (props) => {
const { comments, doc, introContent } = props
return (
<div className={classes.commentsBlock}>
{introContent && (
<Gutter className={classes.introContent}>
<RichText content={introContent} />
</Gutter>
)}
<Gutter>
<div className={classes.comments}>
<HR />
{comments?.map((com, index) => {
const { _status, comment, createdAt, populatedUser } = com
if (!comment) return null
return (
<Fragment key={index}>
<div
className={[
classes.column,
comments.length === 2 && classes['cols-half'],
comments.length >= 3 && classes['cols-thirds'],
]
.filter(Boolean)
.join(' ')}
>
{_status === 'draft' && (
<Message
message={
<Fragment>
{
'This is a draft comment. You are seeing it because you are an admin. To approve this comment, '
}
<Link
href={`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/comments/${com.id}`}
>
navigate to the admin dashboard
</Link>
{' and click "publish".'}
</Fragment>
}
/>
)}
<p className={classes.comment}>"{comment}"</p>
{populatedUser && (
<p className={classes.meta}>
{populatedUser?.name || 'Unnamed User'}
{createdAt && ` on ${formatDateTime(createdAt)}`}
</p>
)}
</div>
{index < comments.length - 1 && <HR />}
</Fragment>
)
})}
<HR />
<CommentForm docID={doc.id} />
</div>
</Gutter>
</div>
)
}

View File

@@ -1,42 +0,0 @@
@import '../../_css/common';
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: var(--base) calc(var(--base) * 2);
@include mid-break {
grid-template-columns: repeat(6, 1fr);
gap: var(--base) var(--base);
}
}
.column--oneThird {
grid-column-end: span 4;
}
.column--half {
grid-column-end: span 6;
}
.column--twoThirds {
grid-column-end: span 8;
}
.column--full {
grid-column-end: span 12;
}
.column {
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}
.link {
margin-top: var(--base);
}

View File

@@ -1,37 +0,0 @@
import React from 'react'
import type { Page } from '../../../payload/payload-types'
import { Gutter } from '../../_components/Gutter'
import { CMSLink } from '../../_components/Link'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
type Props = Extract<Page['layout'][0], { blockType: 'content' }>
export const ContentBlock: React.FC<
Props & {
id?: string
}
> = (props) => {
const { columns } = props
return (
<Gutter className={classes.content}>
<div className={classes.grid}>
{columns &&
columns.length > 0 &&
columns.map((col, index) => {
const { enableLink, link, richText, size } = col
return (
<div className={[classes.column, classes[`column--${size}`]].join(' ')} key={index}>
<RichText content={richText} />
{enableLink && <CMSLink className={classes.link} {...link} />}
</div>
)
})}
</div>
</Gutter>
)
}

View File

@@ -1,8 +0,0 @@
.mediaBlock {
position: relative;
}
.caption {
color: var(--theme-elevation-500);
margin-top: var(--base);
}

View File

@@ -1,42 +0,0 @@
import type { StaticImageData } from 'next/image'
import React from 'react'
import type { Page } from '../../../payload/payload-types'
import { Gutter } from '../../_components/Gutter'
import { Media } from '../../_components/Media'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
type Props = Extract<Page['layout'][0], { blockType: 'mediaBlock' }> & {
id?: string
staticImage?: StaticImageData
}
export const MediaBlock: React.FC<Props> = (props) => {
const { media, position = 'default', staticImage } = props
let caption
if (media && typeof media === 'object') caption = media.caption
return (
<div className={classes.mediaBlock}>
{position === 'fullscreen' && (
<div className={classes.fullscreen}>
<Media resource={media} src={staticImage} />
</div>
)}
{position === 'default' && (
<Gutter>
<Media resource={media} src={staticImage} />
</Gutter>
)}
{caption && (
<Gutter className={classes.caption}>
<RichText content={caption} />
</Gutter>
)}
</div>
)
}

View File

@@ -1,58 +0,0 @@
@import '../../_css/common';
.introContent {
position: relative;
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: var(--base);
}
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
width: 100%;
gap: var(--base) 40px;
@include mid-break {
grid-template-columns: repeat(6, 1fr);
gap: calc(var(--base) / 2) var(--base);
}
}
.column {
grid-column-end: span 12;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}
.cols-half {
grid-column-end: span 6;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}
.cols-thirds {
grid-column-end: span 3;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}

View File

@@ -1,52 +0,0 @@
import React from 'react'
import type { Post, Project } from '../../../payload/payload-types'
import { Card } from '../../_components/Card'
import { Gutter } from '../../_components/Gutter'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
export type RelatedPostsProps = {
blockName: string
blockType: 'relatedPosts'
docs?: (Post | Project | string)[]
introContent?: any
relationTo: 'posts' | 'projects'
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
const { docs, introContent, relationTo } = props
return (
<div className={classes.relatedPosts}>
{introContent && (
<Gutter className={classes.introContent}>
<RichText content={introContent} />
</Gutter>
)}
<Gutter>
<div className={classes.grid}>
{docs?.map((doc, index) => {
if (typeof doc === 'string') return null
return (
<div
className={[
classes.column,
docs.length === 2 && classes['cols-half'],
docs.length >= 3 && classes['cols-thirds'],
]
.filter(Boolean)
.join(' ')}
key={index}
>
<Card doc={doc} relationTo={relationTo} showCategories />
</div>
)
})}
</div>
</Gutter>
</div>
)
}

View File

@@ -1,53 +0,0 @@
.adminBar {
z-index: 10;
width: 100%;
padding: 5px 0;
font-size: calc(#{var(--html-font-size)} * 1px);
display: block;
visibility: hidden;
opacity: 0;
background-color: var(--theme-elevation-100);
transition: opacity 150ms linear;
}
:global([data-theme='dark']) {
.adminBar {
background-color: var(--theme-elevation-0);
}
}
.payloadAdminBar {
color: var(--theme-text) !important;
}
.show {
visibility: visible;
opacity: 1;
}
.controls {
& > *:not(:last-child) {
margin-right: 10px !important;
}
}
.user {
margin-right: 10px !important;
}
.logo {
margin-right: 10px !important;
}
.blockContainer {
position: relative;
}
.hr {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background-color: var(--light-gray);
height: 2px;
}

View File

@@ -1,11 +0,0 @@
.invert {
background-color: var(--theme-text);
color: var(--theme-bg);
}
:global([data-theme='dark']) {
.invert {
background-color: var(--theme-elevation-100);
color: var(--theme-text);
}
}

View File

@@ -1,20 +0,0 @@
import React from 'react'
import classes from './index.module.scss'
type Props = {
children?: React.ReactNode
className?: string
id?: string
invert?: boolean
}
export const BackgroundColor: React.FC<Props> = (props) => {
const { id, children, className, invert } = props
return (
<div className={[invert && classes.invert, className].filter(Boolean).join(' ')} id={id}>
{children}
</div>
)
}

View File

@@ -1,85 +0,0 @@
import React, { Fragment } from 'react'
import type { Page } from '../../../payload/payload-types.js'
import type { VerticalPaddingOptions } from '../VerticalPadding'
import { ArchiveBlock } from '../../_blocks/ArchiveBlock'
import { CallToActionBlock } from '../../_blocks/CallToAction'
import { CommentsBlock, type CommentsBlockProps } from '../../_blocks/Comments/index'
import { ContentBlock } from '../../_blocks/Content'
import { MediaBlock } from '../../_blocks/MediaBlock'
import { RelatedPosts, type RelatedPostsProps } from '../../_blocks/RelatedPosts'
import { toKebabCase } from '../../_utilities/toKebabCase'
import { BackgroundColor } from '../BackgroundColor'
import { VerticalPadding } from '../VerticalPadding'
const blockComponents = {
archive: ArchiveBlock,
comments: CommentsBlock,
content: ContentBlock,
cta: CallToActionBlock,
mediaBlock: MediaBlock,
relatedPosts: RelatedPosts,
}
export const Blocks: React.FC<{
blocks: (CommentsBlockProps | Page['layout'][0] | RelatedPostsProps)[]
disableTopPadding?: boolean
}> = (props) => {
const { blocks, disableTopPadding } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
if (hasBlocks) {
return (
<Fragment>
{blocks.map((block, index) => {
const { blockName, blockType } = block
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType]
// the cta block is containerized, so we don't consider it to be inverted at the block-level
const blockIsInverted =
'invertBackground' in block && blockType !== 'cta' ? block.invertBackground : false
const prevBlock = blocks[index - 1]
const prevBlockInverted =
prevBlock && 'invertBackground' in prevBlock && prevBlock?.invertBackground
const isPrevSame = Boolean(blockIsInverted) === Boolean(prevBlockInverted)
let paddingTop: VerticalPaddingOptions = 'large'
let paddingBottom: VerticalPaddingOptions = 'large'
if (prevBlock && isPrevSame) {
paddingTop = 'none'
}
if (index === blocks.length - 1) {
paddingBottom = 'large'
}
if (disableTopPadding && index === 0) {
paddingTop = 'none'
}
if (Block) {
return (
<BackgroundColor invert={blockIsInverted} key={index}>
<VerticalPadding bottom={paddingBottom} top={paddingTop}>
{/* @ts-expect-error */}
<Block id={toKebabCase(blockName)} {...block} />
</VerticalPadding>
</BackgroundColor>
)
}
}
return null
})}
</Fragment>
)
}
return null
}

View File

@@ -1,72 +0,0 @@
@import '../../_css/type.scss';
.button {
border: none;
cursor: pointer;
display: inline-flex;
justify-content: center;
background-color: transparent;
text-decoration: none;
display: inline-flex;
padding: 12px 24px;
font-family: inherit;
line-height: inherit;
font-size: inherit;
}
.content {
display: flex;
align-items: center;
justify-content: space-around;
svg {
margin-right: calc(var(--base) / 2);
width: var(--base);
height: var(--base);
}
}
.label {
@extend %label;
text-align: center;
display: flex;
align-items: center;
}
.appearance--primary {
background-color: var(--theme-elevation-1000);
color: var(--theme-elevation-0);
}
.appearance--secondary {
background-color: transparent;
box-shadow: inset 0 0 0 1px var(--theme-elevation-1000);
}
.primary--invert {
background-color: var(--theme-elevation-0);
color: var(--theme-elevation-1000);
}
.secondary--invert {
background-color: var(--theme-elevation-1000);
box-shadow: inset 0 0 0 1px var(--theme-elevation-0);
}
.appearance--default {
padding: 0;
color: var(--theme-text);
}
.appearance--none {
padding: 0;
color: var(--theme-text);
&:local() {
.label {
text-transform: none;
line-height: inherit;
font-size: inherit;
}
}
}

View File

@@ -1,78 +0,0 @@
'use client'
import type { ElementType } from 'react'
import Link from 'next/link'
import React from 'react'
import classes from './index.module.scss'
export type Props = {
appearance?: 'default' | 'none' | 'primary' | 'secondary'
className?: string
disabled?: boolean
el?: 'a' | 'button' | 'link'
href?: string
invert?: boolean
label?: string
newTab?: boolean
onClick?: () => void
type?: 'button' | 'submit'
}
export const Button: React.FC<Props> = ({
type = 'button',
appearance,
className: classNameFromProps,
disabled,
el: elFromProps = 'link',
href,
invert,
label,
newTab,
onClick,
}) => {
let el = elFromProps
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
const className = [
classes.button,
classNameFromProps,
classes[`appearance--${appearance}`],
invert && classes[`${appearance}--invert`],
]
.filter(Boolean)
.join(' ')
const content = (
<div className={classes.content}>
<span className={classes.label}>{label}</span>
</div>
)
if (onClick || type === 'submit') el = 'button'
if (el === 'link') {
return (
<Link className={className} href={href || ''} {...newTabProps} onClick={onClick}>
{content}
</Link>
)
}
const Element: ElementType = el
return (
<Element
className={className}
href={href}
type={type}
{...newTabProps}
disabled={disabled}
onClick={onClick}
>
{content}
</Element>
)
}

View File

@@ -1,106 +0,0 @@
@import '../../_css/common';
.card {
border: 1px var(--theme-elevation-200) solid;
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
}
.vertical {
flex-direction: column;
}
.horizontal {
flex-direction: row;
&:local() {
.mediaWrapper {
width: 150px;
@include mid-break {
width: 100%;
}
}
}
@include mid-break {
flex-direction: column;
}
}
.content {
padding: var(--base);
flex-grow: 1;
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
@include small-break {
padding: calc(var(--base) / 2);
gap: calc(var(--base) / 4);
}
}
.title {
margin: 0;
}
.titleLink {
text-decoration: none;
}
.centerAlign {
align-items: center;
}
.body {
flex-grow: 1;
}
.leader {
@extend %label;
display: flex;
gap: var(--base);
}
.description {
margin: 0;
}
.hideImageOnMobile {
@include mid-break {
display: none;
}
}
.mediaWrapper {
text-decoration: none;
display: block;
position: relative;
aspect-ratio: 16 / 9;
}
.image {
object-fit: cover;
}
.placeholder {
background-color: var(--theme-elevation-50);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.actions {
display: flex;
align-items: center;
@include mid-break {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,26 +0,0 @@
import React from 'react'
export const Chevron: React.FC<{
className?: string
rotate?: number
}> = ({ className, rotate }) => {
return (
<svg
className={className}
height="100%"
style={{
transform: typeof rotate === 'number' ? `rotate(${rotate || 0}deg)` : undefined,
}}
viewBox="0 0 24 24"
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.245 4l-11.245 14.374-11.219-14.374-.781.619 12 15.381 12-15.391-.755-.609z"
fill="none"
stroke="currentColor"
vectorEffect="non-scaling-stroke"
/>
</svg>
)
}

View File

@@ -1,73 +0,0 @@
@import '../../_css/common';
// this is to make up for the space taken by the fixed header, since the scroll method does not accept an offset parameter
.scrollRef {
position: absolute;
left: 0;
top: calc(var(--base) * -5);
@include mid-break {
top: calc(var(--base) * -2);
}
}
.introContent {
position: relative;
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: var(--base);
}
}
.resultCountWrapper {
display: flex;
margin-bottom: calc(var(--base) * 2);
@include mid-break {
margin-bottom: var(--base);
}
}
.pageRange {
margin-bottom: var(--base);
@include mid-break {
margin-bottom: var(--base);
}
}
.list {
position: relative;
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
width: 100%;
gap: var(--base) 40px;
@include mid-break {
grid-template-columns: repeat(6, 1fr);
gap: calc(var(--base) / 2) var(--base);
}
}
.column {
grid-column-end: span 4;
@include mid-break {
grid-column-end: span 6;
}
@include small-break {
grid-column-end: span 6;
}
}
.pagination {
margin-top: calc(var(--base) * 2);
@include mid-break {
margin-top: var(--base);
}
}

View File

@@ -1,210 +0,0 @@
'use client'
import qs from 'qs'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import type { Post, Project } from '../../../payload/payload-types'
import type { ArchiveBlockProps } from '../../_blocks/ArchiveBlock/types'
import { Card } from '../Card'
import { Gutter } from '../Gutter'
import { PageRange } from '../PageRange'
import { Pagination } from '../Pagination'
import classes from './index.module.scss'
type Result = {
docs: (Post | Project | string)[]
hasNextPage: boolean
hasPrevPage: boolean
nextPage: number
page: number
prevPage: number
totalDocs: number
totalPages: number
}
export type Props = {
categories?: ArchiveBlockProps['categories']
className?: string
limit?: number
onResultChange?: (result: Result) => void // eslint-disable-line no-unused-vars
populateBy?: 'collection' | 'selection'
populatedDocs?: ArchiveBlockProps['populatedDocs']
populatedDocsTotal?: ArchiveBlockProps['populatedDocsTotal']
relationTo?: 'posts' | 'projects'
selectedDocs?: ArchiveBlockProps['selectedDocs']
showPageRange?: boolean
sort?: string
}
export const CollectionArchive: React.FC<Props> = (props) => {
const {
categories: catsFromProps,
className,
limit = 10,
onResultChange,
populateBy,
populatedDocs,
populatedDocsTotal,
relationTo,
selectedDocs,
showPageRange,
sort = '-createdAt',
} = props
const [results, setResults] = useState<Result>({
docs: (populateBy === 'collection'
? populatedDocs
: populateBy === 'selection'
? selectedDocs
: []
)?.map((doc) => doc.value),
hasNextPage: false,
hasPrevPage: false,
nextPage: 1,
page: 1,
prevPage: 1,
totalDocs: typeof populatedDocsTotal === 'number' ? populatedDocsTotal : 0,
totalPages: 1,
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const scrollRef = useRef<HTMLDivElement>(null)
const hasHydrated = useRef(false)
const isRequesting = useRef(false)
const [page, setPage] = useState(1)
const categories = (catsFromProps || [])
.map((cat) => (typeof cat === 'object' ? cat.id : cat))
.join(',')
const scrollToRef = useCallback(() => {
const { current } = scrollRef
if (current) {
// current.scrollIntoView({
// behavior: 'smooth',
// })
}
}, [])
useEffect(() => {
if (!isLoading && typeof results.page !== 'undefined') {
// scrollToRef()
}
}, [isLoading, scrollToRef, results])
useEffect(() => {
let timer: NodeJS.Timeout = null
if (populateBy === 'collection' && !isRequesting.current) {
isRequesting.current = true
// hydrate the block with fresh content after first render
// don't show loader unless the request takes longer than x ms
// and don't show it during initial hydration
timer = setTimeout(() => {
if (hasHydrated.current) {
setIsLoading(true)
}
}, 500)
const searchQuery = qs.stringify(
{
depth: 1,
limit,
page,
sort,
where: {
...(categories
? {
categories: {
in: categories,
},
}
: {}),
},
},
{ encode: false },
)
const makeRequest = async () => {
try {
const req = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/${relationTo}?${searchQuery}`,
)
const json = await req.json()
clearTimeout(timer)
const { docs } = json as { docs: (Post | Project)[] }
if (docs && Array.isArray(docs)) {
setResults(json)
setIsLoading(false)
if (typeof onResultChange === 'function') {
onResultChange(json)
}
}
} catch (err) {
console.warn(err) // eslint-disable-line no-console
setIsLoading(false)
setError(`Unable to load "${relationTo} archive" data at this time.`)
}
isRequesting.current = false
hasHydrated.current = true
}
void makeRequest()
}
return () => {
if (timer) clearTimeout(timer)
}
}, [page, categories, relationTo, onResultChange, sort, limit, populateBy])
return (
<div className={[classes.collectionArchive, className].filter(Boolean).join(' ')}>
<div className={classes.scrollRef} ref={scrollRef} />
{!isLoading && error && <Gutter>{error}</Gutter>}
<Fragment>
{showPageRange !== false && populateBy !== 'selection' && (
<Gutter>
<div className={classes.pageRange}>
<PageRange
collection={relationTo}
currentPage={results.page}
limit={limit}
totalDocs={results.totalDocs}
/>
</div>
</Gutter>
)}
<Gutter>
<div className={classes.grid}>
{results.docs?.map((result, index) => {
if (typeof result === 'object' && result !== null) {
return (
<div className={classes.column} key={index}>
<Card doc={result} relationTo={relationTo} showCategories />
</div>
)
}
return null
})}
</div>
{results.totalPages > 1 && populateBy !== 'selection' && (
<Pagination
className={classes.pagination}
onClick={setPage}
page={results.page}
totalPages={results.totalPages}
/>
)}
</Gutter>
</Fragment>
</div>
)
}

View File

@@ -1,43 +0,0 @@
@use '../../_css/queries.scss' as *;
.footer {
padding: calc(var(--base) * 4) 0;
background-color: var(--theme-elevation-1000);
color: var(--theme-elevation-0);
@include small-break {
padding: calc(var(--base) * 2) 0;
}
}
:global([data-theme='dark']) {
.footer {
background-color: var(--theme-elevation-50);
color: var(--theme-elevation-1000);
}
}
.wrap {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: calc(var(--base) / 2) var(--base);
}
.logo {
width: 150px;
}
.nav {
display: flex;
gap: calc(var(--base) / 4) var(--base);
align-items: center;
flex-wrap: wrap;
opacity: 1;
transition: opacity 100ms linear;
visibility: visible;
> * {
text-decoration: none;
}
}

View File

@@ -1,58 +0,0 @@
import Link from 'next/link'
import React from 'react'
import type { Footer } from '../../../payload/payload-types'
import { fetchFooter, fetchGlobals } from '../../_api/fetchGlobals'
import { ThemeSelector } from '../../_providers/Theme/ThemeSelector'
import { Gutter } from '../Gutter'
import { CMSLink } from '../Link'
import classes from './index.module.scss'
export async function Footer() {
let footer: Footer | null = null
try {
footer = await fetchFooter()
} catch (error) {
// When deploying this template on Payload Cloud, this page needs to build before the APIs are live
// So swallow the error here and simply render the footer without nav items if one occurs
// in production you may want to redirect to a 404 page or at least log the error somewhere
// console.error(error)
}
const navItems = footer?.navItems || []
return (
<footer className={classes.footer}>
<Gutter className={classes.wrap}>
<Link href="/">
<picture>
<img
alt="Payload Logo"
className={classes.logo}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
/>
</picture>
</Link>
<nav className={classes.nav}>
<ThemeSelector />
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} />
})}
<Link href="/admin">Admin</Link>
<Link
href="https://github.com/payloadcms/payload/tree/main/templates/website"
rel="noopener noreferrer"
target="_blank"
>
Source Code
</Link>
<Link href="https://payloadcms.com" rel="noopener noreferrer" target="_blank">
Payload
</Link>
</nav>
</Gutter>
</footer>
)
}

View File

@@ -1,13 +0,0 @@
.gutter {
max-width: 1920px;
margin-left: auto;
margin-right: auto;
}
.gutterLeft {
padding-left: var(--gutter-h);
}
.gutterRight {
padding-right: var(--gutter-h);
}

View File

@@ -1,35 +0,0 @@
import type { Ref } from 'react'
import React, { forwardRef } from 'react'
import classes from './index.module.scss'
type Props = {
children: React.ReactNode
className?: string
left?: boolean
ref?: Ref<HTMLDivElement>
right?: boolean
}
export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
const { children, className, left = true, right = true } = props
return (
<div
className={[
classes.gutter,
left && classes.gutterLeft,
right && classes.gutterRight,
className,
]
.filter(Boolean)
.join(' ')}
ref={ref}
>
{children}
</div>
)
})
Gutter.displayName = 'Gutter'

View File

@@ -1,8 +0,0 @@
@import '../../_css/type.scss';
.hr {
margin: calc(var(--block-padding) / 2) 0;
border: none;
background-color: var(--theme-elevation-200);
height: 1px;
}

View File

@@ -1,11 +0,0 @@
import React from 'react'
import classes from './index.module.scss'
export const HR: React.FC<{
className?: string
}> = (props) => {
const { className } = props
return <hr className={[className, classes.hr].filter(Boolean).join(' ')} />
}

View File

@@ -1,20 +0,0 @@
@use '../../../_css/queries.scss' as *;
.nav {
display: flex;
gap: calc(var(--base) / 4) var(--base);
align-items: center;
flex-wrap: wrap;
opacity: 1;
transition: opacity 100ms linear;
visibility: visible;
> * {
text-decoration: none;
}
}
.hide {
opacity: 0;
visibility: hidden;
}

View File

@@ -1,42 +0,0 @@
'use client'
import Link from 'next/link'
import React from 'react'
import type { Header as HeaderType } from '../../../../payload/payload-types'
import { useAuth } from '../../../_providers/Auth'
import { CMSLink } from '../../Link'
import classes from './index.module.scss'
export const HeaderNav: React.FC<{ header: HeaderType }> = ({ header }) => {
const navItems = header?.navItems || []
const { user } = useAuth()
return (
<nav
className={[
classes.nav,
// fade the nav in on user load to avoid flash of content and layout shift
// Vercel also does this in their own website header, see https://vercel.com
user === undefined && classes.hide,
]
.filter(Boolean)
.join(' ')}
>
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} appearance="none" />
})}
{user && <Link href="/account">Account</Link>}
{/*
// Uncomment this code if you want to add a login link to the header
{!user && (
<React.Fragment>
<Link href="/login">Login</Link>
<Link href="/create-account">Create Account</Link>
</React.Fragment>
)}
*/}
</nav>
)
}

View File

@@ -1,22 +0,0 @@
@use '../../_css/queries.scss' as *;
.header {
padding: var(--base) 0;
}
.wrap {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: calc(var(--base) / 2) var(--base);
}
.logo {
width: 150px;
}
:global([data-theme='light']) {
.logo {
filter: invert(1);
}
}

View File

@@ -1,48 +0,0 @@
{
/* eslint-disable @next/next/no-img-element */
}
import Link from 'next/link'
import React from 'react'
import type { Header } from '../../../payload/payload-types'
import { fetchHeader } from '../../_api/fetchGlobals'
import { Gutter } from '../Gutter'
import { HeaderNav } from './Nav'
import classes from './index.module.scss'
export async function Header() {
let header: Header | null = null
try {
header = await fetchHeader()
} catch (error) {
// When deploying this template on Payload Cloud, this page needs to build before the APIs are live
// So swallow the error here and simply render the header without nav items if one occurs
// in production you may want to redirect to a 404 page or at least log the error somewhere
// console.error(error)
}
return (
<React.Fragment>
<header className={classes.header}>
<Gutter className={classes.wrap}>
<Link href="/">
{/* Cannot use the `<picture>` element here with `srcSet`
This is because the theme is able to be overridden by the user
And so `@media (prefers-color-scheme: dark)` will not work
Instead, we just use CSS to invert the color via `filter: invert(1)` based on `[data-theme="dark"]`
*/}
<img
alt="Payload Logo"
className={classes.logo}
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
/>
</Link>
<HeaderNav header={header} />
</Gutter>
</header>
</React.Fragment>
)
}

View File

@@ -1,75 +0,0 @@
@import '../../_css/common';
.inputWrap {
width: 100%;
}
.input {
width: 100%;
font-family: system-ui;
border-radius: 0;
box-shadow: none;
border: none;
background: none;
background-color: var(--theme-elevation-100);
color: var(--theme-elevation-1000);
height: calc(var(--base) * 2);
line-height: calc(var(--base) * 2);
padding: 0 calc(var(--base) / 2);
font-size: inherit;
&:focus {
border: none;
outline: none;
}
&::placeholder {
color: var(--theme-elevation-500);
}
&:disabled {
cursor: not-allowed;
pointer-events: none;
color: var(--theme-elevation-500);
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-text-fill-color: var(--theme-text);
-webkit-box-shadow: 0 0 0px 1000px var(--theme-elevation-150) inset;
transition: background-color 5000s ease-in-out 0s;
}
}
.asterisk {
color: var(--color-error-500);
}
.textarea {
height: calc(var(--base) * 5);
}
:global([data-theme='dark']) {
.input {
background-color: var(--theme-elevation-150);
}
}
.error {
background-color: var(--theme-error-150);
}
.label {
margin-bottom: 0;
display: block;
line-height: 1;
margin-bottom: calc(var(--base) / 2);
}
.errorMessage {
font-size: small;
line-height: 1.25;
margin-top: 4px;
color: red;
}

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