feat: add website template (#6470)
Adds the new website template for 3.0
This commit is contained in:
8
templates/website/.gitignore
vendored
8
templates/website/.gitignore
vendored
@@ -1,3 +1,9 @@
|
||||
build
|
||||
dist / media
|
||||
node_modules.DS_Store.env.next.vercel
|
||||
node_modules
|
||||
.DS_Store
|
||||
.env
|
||||
.next
|
||||
.vercel
|
||||
|
||||
media
|
||||
|
||||
@@ -12,15 +12,12 @@ RUN yarn build
|
||||
FROM base as runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PAYLOAD_CONFIG_PATH=dist/payload.config.js
|
||||
|
||||
WORKDIR /home/node/app
|
||||
COPY package*.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
RUN yarn install --production
|
||||
COPY --from=builder /home/node/app/dist ./dist
|
||||
COPY --from=builder /home/node/app/build ./build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
17
templates/website/components.json
Normal file
17
templates/website/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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('; ')
|
||||
@@ -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 |
@@ -1,42 +1,9 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
|
||||
import redirects from './redirects.js'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const ContentSecurityPolicy = require('./csp')
|
||||
const redirects = require('./redirects')
|
||||
|
||||
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: {
|
||||
domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL]
|
||||
.filter(Boolean)
|
||||
@@ -44,7 +11,6 @@ const nextConfig = {
|
||||
},
|
||||
reactStrictMode: true,
|
||||
redirects,
|
||||
swcMinify: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
export default withPayload(nextConfig)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,61 +1,82 @@
|
||||
{
|
||||
"name": "@payloadcms/template-website",
|
||||
"description": "Website template for Payload",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/server.js",
|
||||
"description": "Website template for Payload",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts nodemon",
|
||||
"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",
|
||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload/payload.config.ts payload build",
|
||||
"build:server": "tsc --project tsconfig.server.json",
|
||||
"build:next": "cross-env PAYLOAD_CONFIG_PATH=dist/payload/payload.config.js NEXT_BUILD=true node dist/server.js",
|
||||
"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",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"dev:prod": "rm -rf .next && pnpm build && pnpm serve",
|
||||
"generate:types": "payload generate:types",
|
||||
"ii": "pnpm --ignore-workspace install",
|
||||
"lint": "eslint 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": {
|
||||
"@payloadcms/bundler-webpack": "^1.0.0",
|
||||
"@payloadcms/db-mongodb": "^1.0.0",
|
||||
"@payloadcms/db-postgres": "^0.1.9",
|
||||
"@payloadcms/plugin-cloud": "^3.0.0",
|
||||
"@payloadcms/plugin-nested-docs": "^1.0.8",
|
||||
"@payloadcms/plugin-redirects": "^1.0.0",
|
||||
"@payloadcms/plugin-seo": "^1.0.10",
|
||||
"@payloadcms/richtext-slate": "^1.0.0",
|
||||
"@lexical/list": "^0.15",
|
||||
"@lexical/react": "^0.15",
|
||||
"@lexical/rich-text": "^0.15",
|
||||
"@lexical/utils": "^0.15",
|
||||
"@payloadcms/db-mongodb": "3.0.0-beta.34",
|
||||
"@payloadcms/db-postgres": "3.0.0-beta.34",
|
||||
"@payloadcms/live-preview-react": "3.0.0-beta.34",
|
||||
"@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",
|
||||
"dotenv": "^8.2.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.17.1",
|
||||
"next": "13.5.2",
|
||||
"payload": "^2.0.7",
|
||||
"geist": "^1.3.0",
|
||||
"graphql": "^16.8.1",
|
||||
"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",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"qs": "6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "beta",
|
||||
"react-dom": "beta",
|
||||
"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": {
|
||||
"@next/eslint-plugin-next": "^13.1.6",
|
||||
"@payloadcms/eslint-config": "^1.1.1",
|
||||
"@swc/core": "1.3.76",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/node": "18.11.3",
|
||||
"@types/qs": "^6.9.8",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"copyfiles": "^2.4.1",
|
||||
"nodemon": "^2.0.6",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.0.3",
|
||||
"slate": "0.91.4",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"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
9882
templates/website/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
templates/website/postcss.config.js
Normal file
6
templates/website/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
// Note: This will not work in dev mode and will throw an error upon startup
|
||||
// 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 redirects = async () => {
|
||||
const internetExplorerRedirect = {
|
||||
destination: '/ie-incompatible.html',
|
||||
has: [
|
||||
@@ -17,65 +12,9 @@ module.exports = async () => {
|
||||
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectsRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/redirects?limit=1000&depth=1`,
|
||||
)
|
||||
|
||||
const redirectsData = await redirectsRes.json()
|
||||
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]
|
||||
const redirects = [internetExplorerRedirect]
|
||||
|
||||
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
|
||||
|
||||
76
templates/website/src/app/(frontend)/(pages)/[slug]/page.tsx
Normal file
76
templates/website/src/app/(frontend)/(pages)/[slug]/page.tsx
Normal 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
|
||||
}
|
||||
18
templates/website/src/app/(frontend)/(pages)/not-found.tsx
Normal file
18
templates/website/src/app/(frontend)/(pages)/not-found.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
52
templates/website/src/app/(frontend)/(pages)/posts/page.tsx
Normal file
52
templates/website/src/app/(frontend)/(pages)/posts/page.tsx
Normal 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`,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
93
templates/website/src/app/(frontend)/globals.css
Normal file
93
templates/website/src/app/(frontend)/globals.css
Normal 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;
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
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 { AdminBar } from './_components/AdminBar'
|
||||
import { Footer } from './_components/Footer'
|
||||
import { Header } from './_components/Header'
|
||||
import './_css/app.scss'
|
||||
import { Providers } from './_providers'
|
||||
import { InitTheme } from './_providers/Theme/InitTheme'
|
||||
import { mergeOpenGraph } from './_utilities/mergeOpenGraph'
|
||||
import { AdminBar } from '../components/AdminBar'
|
||||
import { Footer } from '../components/Footer'
|
||||
import { Header } from '../components/Header'
|
||||
import { LivePreviewListener } from '../components/LivePreviewListener'
|
||||
import { Providers } from '../providers'
|
||||
import { InitTheme } from '../providers/Theme/InitTheme'
|
||||
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 }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html className={cn(GeistSans.variable, GeistMono.variable)} lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<InitTheme />
|
||||
<link href="/favicon.ico" rel="icon" sizes="32x32" />
|
||||
@@ -21,10 +26,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<body>
|
||||
<Providers>
|
||||
<AdminBar />
|
||||
{/* @ts-expect-error */}
|
||||
<LivePreviewListener />
|
||||
|
||||
<Header />
|
||||
{children}
|
||||
{/* @ts-expect-error */}
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
@import '../../_css/common';
|
||||
|
||||
.login {
|
||||
margin-bottom: var(--block-padding);
|
||||
}
|
||||
|
||||
.params {
|
||||
margin-top: var(--base);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.logout {
|
||||
margin-bottom: var(--block-padding);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@import '../../_css/common';
|
||||
|
||||
.recoverPassword {
|
||||
margin-bottom: var(--block-padding);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@import '../../../_css/common';
|
||||
|
||||
.form {
|
||||
width: 66.66%;
|
||||
|
||||
@include mid-break {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: var(--base);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.resetPassword {
|
||||
margin-bottom: var(--block-padding);
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
6
templates/website/src/app/(payload)/api/graphql/route.ts
Normal file
6
templates/website/src/app/(payload)/api/graphql/route.ts
Normal 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)
|
||||
0
templates/website/src/app/(payload)/custom.scss
Normal file
0
templates/website/src/app/(payload)/custom.scss
Normal file
16
templates/website/src/app/(payload)/layout.tsx
Normal file
16
templates/website/src/app/(payload)/layout.tsx
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
export const payloadToken = 'payload-token'
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.mediaBlock {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.caption {
|
||||
color: var(--theme-elevation-500);
|
||||
margin-top: var(--base);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(' ')} />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user