chore: scaffolds redirects example (#1993)

This commit is contained in:
PatrikKozak
2023-02-04 10:25:25 -05:00
committed by GitHub
parent 071462b33b
commit d51e93b86e
68 changed files with 2700 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
PAYLOAD_PUBLIC_SITE_URL=http://localhost:3000
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000
MONGODB_URI=mongodb://localhost/redirects-example
PAYLOAD_SECRET=ENTER-STRING-HERE

View File

@@ -0,0 +1,3 @@
{
"quote-props": "disabled"
}

170
examples/redirects/cms/.gitignore vendored Normal file
View File

@@ -0,0 +1,170 @@
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
yarn.lock
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# Support for Project snippet scope
.vscode/*.code-snippets
# Ignore code-workspaces
*.code-workspace
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
/media

View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Redirects Example CMS",
"program": "${workspaceFolder}/src/server.ts",
"preLaunchTask": "npm: build:server",
"env": {
"PAYLOAD_CONFIG_PATH": "${workspaceFolder}/src/payload.config.ts"
},
// "outFiles": [
// "${workspaceFolder}/dist/**/*.js"
// ]
},
]
}

View File

@@ -0,0 +1,26 @@
# Redirects Example with Payload CMS
This is an example repo that showcases how to implement redirects into your Payload CMS.
## Getting Started
1. Clone this repo
2. `cd` into the directory and run `yarn` or `npm install`
3. Copy (`cp`) the `.env.example` file to an `.env` file
4. Run `yarn dev` or `npm run dev` to start the development server
5. Visit `http://localhost:8000/admin` to access the admin panel
6. Login with the following credentials:
- Email: `dev@payloadcms.com`
- Password: `test`
## Frontend Development
To get the front-end side started up - open the `nextjs` folder that is along side `cms`. Follow the instructions there to get started. You can use this repo as a backend for the frontend and see for yourself how it all works together.
## Usage
Once booted up, some instructions on how to view redirects working in action will be immediately available to you on the home page along with some basic content on corresponding pages.
- On initial start up, a `Home` page and a `Redirect` page will be seeded into the `pages` collection.
- A couple redirects have also been seeded into the `redirects` collection as examples. Accessing the url `/old-internal-link` will redirect you to the `Redirect Page`. While accessing the url `/old-external-link` will redirect you to the custom url `payloadcms.com`.
- The redirects plugin is very easy to use. Once added, all you need to do is input a `From URL` and a `To URL`!

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -0,0 +1,34 @@
{
"name": "redirects-example-cms",
"description": "The CMS is used to demonstrate the redirects feature.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema"
},
"dependencies": {
"@faceless-ui/modal": "^2.0.1",
"@payloadcms/plugin-nested-docs": "^1.0.4",
"@payloadcms/plugin-redirects": "^1.0.0",
"@payloadcms/plugin-seo": "^1.0.8",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "^1.6.2"
},
"devDependencies": {
"@types/express": "^4.17.9",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
}
}

View File

@@ -0,0 +1,31 @@
import { CollectionConfig } from 'payload/types';
import { slugField } from '../fields/slug';
import richText from '../fields/richText';
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
versions: {
drafts: true,
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
type: 'row',
fields: [
richText(),
],
},
slugField(),
],
};

View File

@@ -0,0 +1,12 @@
import { CollectionConfig } from 'payload/types';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [
// Don't need any user fields here
],
};

View File

@@ -0,0 +1,151 @@
import { Field } from 'payload/types';
import deepMerge from '../utilities/deepMerge';
export const appearanceOptions = {
primary: {
label: 'Primary Button',
value: 'primary',
},
secondary: {
label: 'Secondary Button',
value: 'secondary',
},
default: {
label: 'Default',
value: 'default',
},
};
export type LinkAppearances = 'primary' | 'secondary' | 'default'
type LinkType = (
options?: {
appearances?: LinkAppearances[] | false
disableLabel?: boolean
overrides?: Record<string, unknown>
}
) => Field;
const link: LinkType = ({
appearances,
disableLabel = false,
overrides = {},
} = {}) => {
let linkResult: Field = {
name: 'link',
type: 'group',
admin: {
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
defaultValue: 'reference',
admin: {
layout: 'horizontal',
width: '50%',
},
},
{
name: 'newTab',
label: 'Open in new tab',
type: 'checkbox',
admin: {
width: '50%',
style: {
alignSelf: 'flex-end',
}
}
}
],
},
],
};
let linkTypes: Field[] = [
{
name: 'reference',
label: 'Document to link to',
type: 'relationship',
relationTo: ['pages'],
required: true,
maxDepth: 1,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
label: 'Custom URL',
type: 'text',
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
},
]
if (!disableLabel) {
linkTypes[0].admin.width = '50%';
linkTypes[1].admin.width = '50%';
linkResult.fields.push({
type: 'row',
fields: [
...linkTypes,
{
name: 'label',
label: 'Label',
type: 'text',
required: true,
admin: {
width: '50%',
}
}
]
})
} else {
linkResult.fields = [...linkResult.fields, ...linkTypes];
}
if (appearances !== false) {
let appearanceOptionsToUse = [
appearanceOptions.default,
appearanceOptions.primary,
appearanceOptions.secondary,
]
if (appearances) {
appearanceOptionsToUse = appearances.map((appearance) => appearanceOptions[appearance])
}
linkResult.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'default',
options: appearanceOptionsToUse,
admin: {
description: 'Choose how the link should be rendered.'
}
});
}
return deepMerge(linkResult, overrides);
};
export default link;

View File

@@ -0,0 +1,13 @@
import { RichTextElement } from 'payload/dist/fields/config/types';
const elements: RichTextElement[] = [
'blockquote',
'h2',
'h3',
'h4',
'h5',
'h6',
'link',
];
export default elements;

View File

@@ -0,0 +1,94 @@
import { RichTextElement, RichTextField, RichTextLeaf } from 'payload/dist/fields/config/types';
import deepMerge from '../../utilities/deepMerge';
import elements from './elements';
import leaves from './leaves';
import link from '../link';
type RichText = (
overrides?: Partial<RichTextField>,
additions?: {
elements?: RichTextElement[]
leaves?: RichTextLeaf[]
}
) => RichTextField
const richText: RichText = (
overrides,
additions = {
elements: [],
leaves: [],
},
) => deepMerge<RichTextField, Partial<RichTextField>>(
{
name: 'richText',
type: 'richText',
required: true,
admin: {
upload: {
collections: {
media: {
fields: [
{
type: 'richText',
name: 'caption',
label: 'Caption',
admin: {
elements: [
...elements,
],
leaves: [
...leaves,
],
},
},
{
type: 'radio',
name: 'alignment',
label: 'Alignment',
options: [
{
label: 'Left',
value: 'left',
},
{
label: 'Center',
value: 'center',
},
{
label: 'Right',
value: 'right',
},
],
},
{
name: 'enableLink',
type: 'checkbox',
label: 'Enable Link',
},
link({
appearances: false,
disableLabel: true,
overrides: {
admin: {
condition: (_, data) => Boolean(data?.enableLink),
},
},
}),
],
},
},
},
elements: [
...elements,
...additions.elements || [],
],
leaves: [
...leaves,
...additions.leaves || [],
],
},
},
overrides,
);
export default richText;

View File

@@ -0,0 +1,9 @@
import { RichTextLeaf } from 'payload/dist/fields/config/types';
const defaultLeaves: RichTextLeaf[] = [
'bold',
'italic',
'underline',
];
export default defaultLeaves;

View File

@@ -0,0 +1,23 @@
import { Field } from 'payload/types';
import formatSlug from '../utilities/formatSlug';
import deepMerge from '../utilities/deepMerge';
type Slug = (fieldToUse?: string, overrides?: Partial<Field>) => Field
export const slugField: Slug = (fieldToUse = 'title', overrides) => deepMerge<Field, Partial<Field>>(
{
name: 'slug',
label: 'Slug',
type: 'text',
index: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [
formatSlug(fieldToUse),
],
},
},
overrides,
);

View File

@@ -0,0 +1,21 @@
import { GlobalConfig } from "payload/types";
import link from "../fields/link";
export const MainMenu: GlobalConfig = {
slug: 'main-menu',
access: {
read: () => true,
},
fields: [
{
name: 'navItems',
type: 'array',
maxRows: 6,
fields: [
link({
appearances: false,
}),
]
}
]
}

View File

@@ -0,0 +1,75 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
pages: Page;
users: User;
redirects: Redirect;
};
globals: {
'main-menu': MainMenu;
};
}
export interface Page {
id: string;
title: string;
richText: {
[k: string]: unknown;
}[];
slug?: string;
parent?: string | Page;
breadcrumbs: {
doc?: string | Page;
url?: string;
label?: string;
id?: string;
}[];
_status?: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}
export interface Redirect {
id: string;
from: string;
to: {
type?: 'reference' | 'custom';
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
};
createdAt: string;
updatedAt: string;
}
export interface MainMenu {
id: string;
navItems: {
link: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
label: string;
};
id?: string;
}[];
}

View File

@@ -0,0 +1,36 @@
import { buildConfig } from 'payload/config';
import nestedDocs from '@payloadcms/plugin-nested-docs';
import redirects from '@payloadcms/plugin-redirects';
import path from 'path';
import { Users } from './collections/Users';
import { Pages } from './collections/Pages';
import { MainMenu } from './globals/MainMenu';
export default buildConfig({
collections: [
Pages,
Users,
],
cors: [
'http://localhost:3000',
process.env.PAYLOAD_PUBLIC_SITE_URL,
],
globals: [
MainMenu,
],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
plugins: [
nestedDocs({
collections: ['pages'],
generateLabel: (_, doc) => doc.title as string,
generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug}`, ''),
}),
redirects({
collections: [
'pages',
],
}),
],
});

View File

@@ -0,0 +1,10 @@
export const externalRedirect = {
"id": "63dacd1693ec178132c74a6e",
"from": "http://localhost:3000/old-external-link",
"to": {
"type": "custom",
"url": "https://www.payloadcms.com"
},
"createdAt": "2023-02-01T20:35:34.257Z",
"updatedAt": "2023-02-01T20:35:34.257Z"
}

View File

@@ -0,0 +1,72 @@
export const home = {
"title": "Home Page",
"richText": [
{
"children": [
{
"text": "Paste this url: "
},
{
"text": "http://localhost:3000/old-internal-link",
"bold": true
},
{
"text": " into your browser and you will be redirected to the "
},
{
"type": "link",
"linkType": "internal",
"doc": {
"value": "63dacf0c3ef391338957559a",
"relationTo": "pages"
},
"children": [
{
"text": "Redirect Page"
}
]
},
{
"text": ". "
}
]
},
{
"children": [
{
"text": ""
}
]
},
{
"children": [
{
"text": "Paste this url: "
},
{
"text": "http://localhost:3000/old-external-link",
"bold": true
},
{
"text": " into your browser and will be redirected to an external custom - "
},
{
"text": "payloadcms.com",
"bold": true
},
{
"text": "."
}
]
}
],
"slug": "home",
"breadcrumbs": [
{
"doc": "63dad7182a218056390b64b1",
"url": "/home",
"label": "Home Page",
}
],
"_status": "published",
};

View File

@@ -0,0 +1,61 @@
import { Payload } from 'payload';
import { redirectPage } from './redirectPage';
import { home } from './home';
import { internalRedirect } from './internalRedirect';
import { externalRedirect } from './externalRedirect';
export const seed = async (payload: Payload) => {
await payload.create({
collection: 'users',
data: {
email: 'dev@payloadcms.com',
password: 'test',
},
});
const redirectPageJSON = JSON.parse(JSON.stringify(redirectPage));
const { id: redirectPageID } = await payload.create({
collection: 'pages',
data: redirectPageJSON,
});
const internalRedirectJSON = JSON.parse(JSON.stringify(internalRedirect).replace(/{{REDIRECT_PAGE_ID}}/g, redirectPageID));
await payload.create({
collection: 'redirects',
data: internalRedirectJSON,
})
const externalRedirectJSON = JSON.parse(JSON.stringify(externalRedirect));
await payload.create({
collection: 'redirects',
data: externalRedirectJSON,
})
const homepageJSON = JSON.parse(JSON.stringify(home));
await payload.create({
collection: 'pages',
data: homepageJSON,
});
await payload.updateGlobal({
slug: 'main-menu',
data: {
navItems: [
{
link: {
type: 'reference',
reference: {
relationTo: 'pages',
value: redirectPageID
},
label: 'Redirect Page',
}
},
]
}
})
};

View File

@@ -0,0 +1,11 @@
export const internalRedirect = {
"id": "63d98dffded2e292ff69411e",
"from": "http://localhost:3000/old-internal-link",
"to": {
"type": "reference",
"reference": {
"value": "{{REDIRECT_PAGE_ID}}",
"relationTo": "pages"
}
},
};

View File

@@ -0,0 +1,49 @@
export const redirectPage = {
"title": "Redirect Page",
"richText": [
{
"children": [
{
"text": "You have been successfully redirected to this page if you've navigated to this page by trying to access the link "
},
{
"text": "http://localhost:3000/old-internal-link",
"bold": true
},
{
"text": "."
}
]
},
{
"children": [
{
"text": ""
}
]
},
{
"children": [
{
"text": "Or by clicking "
},
{
"text": "Redirect Page",
"italic": true
},
{
"text": " in the header. "
}
]
}
],
"slug": "redirect-page",
"breadcrumbs": [
{
"doc": "63dad7d359b83d90abe6c3ad",
"url": "/redirect-page",
"label": "Redirect Page",
}
],
"_status": "published",
};

View File

@@ -0,0 +1,31 @@
import express from 'express';
import payload from 'payload';
import { seed } from './seed';
require('dotenv').config();
const app = express();
// Redirect root to Admin panel
app.get('/', (_, res) => {
res.redirect('/admin');
});
const start = async () => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
},
});
if (process.env.PAYLOAD_SEED === 'true') {
await seed(payload);
}
app.listen(8000);
};
start();

View File

@@ -0,0 +1,32 @@
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): boolean {
return (item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = deepMerge(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}

View File

@@ -0,0 +1,21 @@
import { FieldHook } from 'payload/types';
const format = (val: string): string => val.replace(/ /g, '-').replace(/[^\w-]+/g, '').toLowerCase();
const formatSlug = (fallback: string): FieldHook => ({ operation, value, originalDoc, data }) => {
if (typeof value === 'string') {
return format(value);
}
if (operation === 'create') {
const fallbackData = (data && data[fallback]) || (originalDoc && originalDoc[fallback]);
if (fallbackData && typeof fallbackData === 'string') {
return format(fallbackData);
}
}
return value;
};
export default formatSlug;

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"resolveJsonModule": true
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true
}
}

View File

@@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = null

View File

@@ -0,0 +1,3 @@
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_CMS_URL=http://localhost:8000
NEXT_PUBLIC_OFFLINE_MODE=true

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

39
examples/redirects/nextjs/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,54 @@
# Redirects Example Front-End
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) that fetches data from [Payload CMS](https://payloadcms.com).
This example repo was made explicitly to demonstrate the ease of using the Redirects plugin to support redirects on your site.
## Getting Started
### Payload CMS
First you'll need a running CMS. If you have not done so already, open up the `cms` folder and follow the setup instructions. Take note of your server URL, you'll need this in the next step.
### Next.js App
First, get your environment setup:
1. First copy the example `.env` file as your own:
```bash
cp .env.example .env
```
1. Then open the `.env` file and paste your Payload server URL:
```bash
NEXT_PUBLIC_CMS_URL=http://localhost:8000
```
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Once running, you will find a couple seeded pages on your local environment.
You can also start editing the pages by modifying the documents within your CMS.
## Learn More
To learn more about PayloadCMS and Next.js, take a look at the following resources:
- [Payload CMS Documentation](https://payloadcms.com/docs) - learn about Payload CMS features and API.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Payload CMS GitHub repository](https://github.com/payloadcms/payload/) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Payload CMS deployment documentaton](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -0,0 +1,47 @@
@import '../../css/type.scss';
.content {
display: flex;
align-items: center;
justify-content: center;
svg {
margin-right: calc(var(--base) / 2);
width: var(--base);
height: var(--base);
}
}
.label {
@extend %label;
display: flex;
align-items: center;
}
.button {
all: unset;
cursor: pointer;
box-sizing: border-box;
display: inline-flex;
position: relative;
text-decoration: none;
padding: 12px 18px;
margin-bottom: var(--base);
align-items: center;
justify-content: center;
}
.appearance--primary {
background-color: var(--color-black);
color: var(--color-white);
}
.appearance--secondary {
background-color: var(--color-white);
box-shadow: inset 0 0 0 1px var(--color-black);
}
.appearance--default {
padding: 0;
margin-left: -8px;
}

View File

@@ -0,0 +1,66 @@
import Link from 'next/link';
import React from 'react';
import classes from './index.module.scss';
export type Props = {
label: string
appearance?: 'default' | 'primary' | 'secondary'
el?: 'button' | 'link' | 'a'
onClick?: () => void
href?: string
form?: string
newTab?: boolean
className?: string
}
const elements = {
a: 'a',
link: Link,
button: 'button',
}
export const Button: React.FC<Props> = ({
el = 'button',
label,
newTab,
href,
form,
appearance,
className: classNameFromProps
}) => {
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
const Element = elements[el];
const className = [classNameFromProps, classes[`appearance--${appearance}`], classes.button].filter(Boolean).join(' ');
const elementProps = {
...newTabProps,
href,
className,
form,
}
const content = (
<div className={classes.content}>
<span className={classes.label}>
{label}
</span>
</div>
)
return (
<Element {...elementProps}>
<React.Fragment>
{el === 'link' && (
<a {...newTabProps} href={href} className={elementProps.className}>
{content}
</a>
)}
{el !== 'link' && (
<React.Fragment>
{content}
</React.Fragment>
)}
</React.Fragment>
</Element>
)
}

View File

@@ -0,0 +1,14 @@
import React, { useEffect } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useRouter } from 'next/router';
export const CloseModalOnRouteChange: React.FC = () => {
const { closeAllModals } = useModal();
const { asPath } = useRouter();
useEffect(() => {
closeAllModals();
}, [asPath, closeAllModals]);
return null;
};

View File

@@ -0,0 +1,7 @@
.gutterLeft {
padding-left: var(--gutter-h);
}
.gutterRight {
padding-right: var(--gutter-h);
}

View File

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

View File

@@ -0,0 +1,31 @@
import { Modal } from "@faceless-ui/modal";
import { HeaderBar } from ".";
import { MainMenu } from "../../payload-types"
import { Gutter } from "../Gutter";
import { CMSLink } from "../Link";
import classes from './mobileMenuModal.module.scss';
type Props = {
navItems: MainMenu['navItems'];
}
export const slug = 'menu-modal';
export const MobileMenuModal: React.FC<Props> = ({ navItems }) => {
return (
<Modal slug={slug} className={classes.mobileMenuModal}>
<HeaderBar />
<Gutter>
<div className={classes.mobileMenuItems}>
{navItems.map(({ link }, i) => {
return (
<CMSLink className={classes.menuItem} key={i} {...link} />
)
})}
</div>
</Gutter>
</Modal>
)
}

View File

@@ -0,0 +1,36 @@
@use '../../css/queries.scss' as *;
.header {
padding: var(--base) 0;
z-index: var(--header-z-index);
}
.wrap {
display: flex;
justify-content: space-between;
}
.nav {
a {
text-decoration: none;
margin-left: var(--base);
}
@include mid-break {
display: none;
}
}
.mobileMenuToggler {
all: unset;
cursor: pointer;
display: none;
&[aria-expanded="true"] {
transform: rotate(-25deg);
}
@include mid-break {
display: block;
}
}

View File

@@ -0,0 +1,56 @@
import { ModalToggler } from '@faceless-ui/modal';
import Link from 'next/link';
import React from 'react';
import { useGlobals } from '../../providers/Globals';
import { Gutter } from '../Gutter';
import { MenuIcon } from '../icons/Menu';
import { CMSLink } from '../Link';
import { Logo } from '../Logo';
import { MobileMenuModal, slug as menuModalSlug } from './MobileMenuModal';
import classes from './index.module.scss';
type HeaderBarProps = {
children?: React.ReactNode;
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ children }) => {
return (
<header className={classes.header}>
<Gutter className={classes.wrap}>
<Link href="/">
<a>
<Logo />
</a>
</Link>
{children}
<ModalToggler slug={menuModalSlug} className={classes.mobileMenuToggler}>
<MenuIcon />
</ModalToggler>
</Gutter>
</header>
)
}
export const Header: React.FC = () => {
const { mainMenu: { navItems } } = useGlobals();
return (
<>
<HeaderBar>
<nav className={classes.nav}>
{navItems.map(({ link }, i) => {
return (
<CMSLink key={i} {...link} />
)
})}
</nav>
</HeaderBar>
<MobileMenuModal navItems={navItems} />
</>
)
}

View File

@@ -0,0 +1,31 @@
@use '../../css/common.scss' as *;
.mobileMenuModal {
position: relative;
width: 100%;
height: 100%;
border: none;
padding: 0;
opacity: 1;
display: none;
@include mid-break {
display: block;
}
}
.contentContainer {
padding: 20px;
}
.mobileMenuItems {
display: flex;
flex-direction: column;
height: 100%;
margin-top: 30px;
}
.menuItem {
@extend %h4;
margin-top: 0;
}

View File

@@ -0,0 +1,66 @@
import Link from 'next/link';
import React from 'react';
import { Page } from '../../payload-types';
import { Button } from '../Button';
type CMSLinkType = {
type?: 'custom' | 'reference'
url?: string
newTab?: boolean
reference?: {
value: string | Page
relationTo: 'pages'
}
label?: string
appearance?: 'default' | 'primary' | 'secondary'
children?: React.ReactNode
className?: string
}
export const CMSLink: React.FC<CMSLinkType> = ({
type,
url,
newTab,
reference,
label,
appearance,
children,
className,
}) => {
const href = (type === 'reference' && typeof reference?.value === 'object' && reference.value.slug) ? `/${reference.value.slug}` : url;
if (!appearance) {
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
if (type === 'custom') {
return (
<a href={url} {...newTabProps} className={className}>
{label && label}
{children && children}
</a>
)
}
if (href) {
return (
<Link href={href}>
<a {...newTabProps} className={className}>
{label && label}
{children && children}
</a>
</Link>
)
}
}
const buttonProps = {
newTab,
href,
appearance,
label,
}
return (
<Button className={className} {...buttonProps} el="link" />
)
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export const Logo: React.FC = () => {
return (
<svg width="123" height="29" viewBox="0 0 123 29" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.7441 22.9997H37.2741V16.3297H41.5981C44.7031 16.3297 46.9801 14.9037 46.9801 11.4537C46.9801 8.00369 44.7031 6.55469 41.5981 6.55469H34.7441V22.9997ZM37.2741 14.1447V8.73969H41.4831C43.3921 8.73969 44.3581 9.59069 44.3581 11.4537C44.3581 13.2937 43.3921 14.1447 41.4831 14.1447H37.2741Z" fill="black" />
<path d="M51.3652 23.3217C53.2742 23.3217 54.6082 22.5627 55.3672 21.3437H55.4132C55.5512 22.6777 56.1492 23.1147 57.2762 23.1147C57.6442 23.1147 58.0352 23.0687 58.4262 22.9767V21.5967C58.2882 21.6197 58.2192 21.6197 58.1502 21.6197C57.7132 21.6197 57.5982 21.1827 57.5982 20.3317V14.9497C57.5982 11.9137 55.6662 10.9017 53.2512 10.9017C49.6632 10.9017 48.1912 12.6727 48.0762 14.9267H50.3762C50.4912 13.3627 51.1122 12.7187 53.1592 12.7187C54.8842 12.7187 55.3902 13.4317 55.3902 14.2827C55.3902 15.4327 54.2632 15.6627 52.4232 16.0077C49.5022 16.5597 47.5242 17.3417 47.5242 19.9637C47.5242 21.9647 49.0192 23.3217 51.3652 23.3217ZM49.8702 19.8027C49.8702 18.5837 50.7442 18.0087 52.8142 17.5947C54.0102 17.3417 55.0222 17.0887 55.3902 16.7437V18.4227C55.3902 20.4697 53.8952 21.5047 51.8712 21.5047C50.4682 21.5047 49.8702 20.9067 49.8702 19.8027Z" fill="black" />
<path d="M61.4996 27.1167C63.3166 27.1167 64.4436 26.1737 65.5706 23.2757L70.2166 11.2697H67.8476L64.6276 20.2397H64.5816L61.1546 11.2697H58.6936L63.4316 22.8847C62.9716 24.7247 61.9136 25.1847 61.0166 25.1847C60.6486 25.1847 60.4416 25.1617 60.0506 25.1157V26.9557C60.6486 27.0707 60.9936 27.1167 61.4996 27.1167Z" fill="black" />
<path d="M71.5939 22.9997H73.8479V6.55469H71.5939V22.9997Z" fill="black" />
<path d="M81.6221 23.3447C85.2791 23.3447 87.4871 20.7917 87.4871 17.1117C87.4871 13.4547 85.2791 10.9017 81.6451 10.9017C77.9651 10.9017 75.7571 13.4777 75.7571 17.1347C75.7571 20.8147 77.9651 23.3447 81.6221 23.3447ZM78.1031 17.1347C78.1031 14.6737 79.2071 12.7877 81.6451 12.7877C84.0371 12.7877 85.1411 14.6737 85.1411 17.1347C85.1411 19.5727 84.0371 21.4817 81.6451 21.4817C79.2071 21.4817 78.1031 19.5727 78.1031 17.1347Z" fill="black" />
<path d="M92.6484 23.3217C94.5574 23.3217 95.8914 22.5627 96.6504 21.3437H96.6964C96.8344 22.6777 97.4324 23.1147 98.5594 23.1147C98.9274 23.1147 99.3184 23.0687 99.7094 22.9767V21.5967C99.5714 21.6197 99.5024 21.6197 99.4334 21.6197C98.9964 21.6197 98.8814 21.1827 98.8814 20.3317V14.9497C98.8814 11.9137 96.9494 10.9017 94.5344 10.9017C90.9464 10.9017 89.4744 12.6727 89.3594 14.9267H91.6594C91.7744 13.3627 92.3954 12.7187 94.4424 12.7187C96.1674 12.7187 96.6734 13.4317 96.6734 14.2827C96.6734 15.4327 95.5464 15.6627 93.7064 16.0077C90.7854 16.5597 88.8074 17.3417 88.8074 19.9637C88.8074 21.9647 90.3024 23.3217 92.6484 23.3217ZM91.1534 19.8027C91.1534 18.5837 92.0274 18.0087 94.0974 17.5947C95.2934 17.3417 96.3054 17.0887 96.6734 16.7437V18.4227C96.6734 20.4697 95.1784 21.5047 93.1544 21.5047C91.7514 21.5047 91.1534 20.9067 91.1534 19.8027Z" fill="black" />
<path d="M106.181 23.3217C108.021 23.3217 109.148 22.4477 109.792 21.6197H109.838V22.9997H112.092V6.55469H109.838V12.6957H109.792C109.148 11.7757 108.021 10.9247 106.181 10.9247C103.191 10.9247 100.914 13.2707 100.914 17.1347C100.914 20.9987 103.191 23.3217 106.181 23.3217ZM103.26 17.1347C103.26 14.8347 104.341 12.8107 106.549 12.8107C108.573 12.8107 109.815 14.4667 109.815 17.1347C109.815 19.7797 108.573 21.4587 106.549 21.4587C104.341 21.4587 103.26 19.4347 103.26 17.1347Z" fill="black" />
<path d="M12.2464 2.33838L22.2871 8.83812V21.1752L14.7265 25.8854V13.5484L4.67383 7.05725L12.2464 2.33838Z" fill="black" />
<path d="M11.477 25.2017V15.5747L3.90039 20.2936L11.477 25.2017Z" fill="black" />
<path d="M120.442 6.30273C119.086 6.30273 117.998 7.29978 117.998 8.75952C117.998 10.2062 119.086 11.1968 120.442 11.1968C121.791 11.1968 122.879 10.2062 122.879 8.75952C122.879 7.29978 121.791 6.30273 120.442 6.30273ZM120.442 10.7601C119.34 10.7601 118.48 9.95207 118.48 8.75952C118.48 7.54742 119.34 6.73935 120.442 6.73935C121.563 6.73935 122.397 7.54742 122.397 8.75952C122.397 9.95207 121.563 10.7601 120.442 10.7601ZM120.52 8.97457L121.048 9.9651H121.641L121.041 8.86378C121.367 8.72042 121.511 8.45975 121.511 8.17302C121.511 7.49528 121.054 7.36495 120.285 7.36495H119.49V9.9651H120.025V8.97457H120.52ZM120.37 7.78853C120.729 7.78853 120.976 7.86673 120.976 8.17953C120.976 8.43368 120.807 8.56402 120.403 8.56402H120.025V7.78853H120.37Z" fill="black" />
</svg>
)
}

View File

@@ -0,0 +1,5 @@
.richText {
:first-child {
margin-top: 0;
}
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import serialize from './serialize';
import classes from './index.module.scss';
const RichText: React.FC<{ className?: string, content: any }> = ({ className, content }) => {
if (!content) {
return null;
}
return (
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
{serialize(content)}
</div>
);
};
export default RichText;

View File

@@ -0,0 +1,160 @@
import React, { Fragment } from 'react';
import escapeHTML from 'escape-html';
import { Text } from 'slate';
// eslint-disable-next-line no-use-before-define
type Children = Leaf[]
type Leaf = {
type: string
value?: {
url: string
alt: string
}
children?: Children
url?: string
[key: string]: unknown
}
const serialize = (children: Children): React.ReactElement[] => children.map((node, i) => {
if (Text.isText(node)) {
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />;
if (node.bold) {
text = (
<strong key={i}>
{text}
</strong>
);
}
if (node.code) {
text = (
<code key={i}>
{text}
</code>
);
}
if (node.italic) {
text = (
<em key={i}>
{text}
</em>
);
}
if (node.underline) {
text = (
<span
style={{ textDecoration: 'underline' }}
key={i}
>
{text}
</span>
);
}
if (node.strikethrough) {
text = (
<span
style={{ textDecoration: 'line-through' }}
key={i}
>
{text}
</span>
);
}
return (
<Fragment key={i}>
{text}
</Fragment>
);
}
if (!node) {
return null;
}
switch (node.type) {
case 'h1':
return (
<h1 key={i}>
{serialize(node.children)}
</h1>
);
case 'h2':
return (
<h2 key={i}>
{serialize(node.children)}
</h2>
);
case 'h3':
return (
<h3 key={i}>
{serialize(node.children)}
</h3>
);
case 'h4':
return (
<h4 key={i}>
{serialize(node.children)}
</h4>
);
case 'h5':
return (
<h5 key={i}>
{serialize(node.children)}
</h5>
);
case 'h6':
return (
<h6 key={i}>
{serialize(node.children)}
</h6>
);
case 'quote':
return (
<blockquote key={i}>
{serialize(node.children)}
</blockquote>
);
case 'ul':
return (
<ul key={i}>
{serialize(node.children)}
</ul>
);
case 'ol':
return (
<ol key={i}>
{serialize(node.children)}
</ol>
);
case 'li':
return (
<li key={i}>
{serialize(node.children)}
</li>
);
case 'link':
return (
<a
href={escapeHTML(node.url)}
key={i}
>
{serialize(node.children)}
</a>
);
default:
return (
<p key={i}>
{serialize(node.children)}
</p>
);
}
});
export default serialize;

View File

@@ -0,0 +1,23 @@
.top-large {
padding-top: var(--block-padding);
}
.top-medium {
padding-top: calc(var(--block-padding) / 2);
}
.top-small {
padding-top: calc(var(--block-padding) / 3);
}
.bottom-large {
padding-bottom: var(--block-padding);
}
.bottom-medium {
padding-bottom: calc(var(--block-padding) / 2);
}
.bottom-small {
padding-bottom: calc(var(--block-padding) / 3);
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import classes from './index.module.scss';
export type VerticalPaddingOptions = 'large' | 'medium' | 'none';
type Props = {
top?: VerticalPaddingOptions
bottom?: VerticalPaddingOptions
children: React.ReactNode
className?: string
}
export const VerticalPadding: React.FC<Props> = ({
top = 'medium',
bottom = 'medium',
className,
children,
}) => {
return (
<div
className={[
className,
classes[`top-${top}`],
classes[`bottom-${bottom}`],
].filter(Boolean).join(' ')}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
export const MenuIcon: React.FC = () => {
return (
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3.5" y="4.5" width="18" height="2" fill="currentColor" />
<rect x="3.5" y="11.5" width="18" height="2" fill="currentColor" />
<rect x="3.5" y="18.5" width="18" height="2" fill="currentColor" />
</svg>
)
}

View File

@@ -0,0 +1,129 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
:root {
--breakpoint-xs-width : #{$breakpoint-xs-width};
--breakpoint-s-width : #{$breakpoint-s-width};
--breakpoint-m-width : #{$breakpoint-m-width};
--breakpoint-l-width : #{$breakpoint-l-width};
--scrollbar-width: 17px;
--base: 24px;
--font-body: system-ui;
--font-mono: 'Roboto Mono', monospace;
--gutter-h: 180px;
--block-padding: 120px;
--header-z-index: 100;
--modal-z-index: 90;
@include large-break {
--gutter-h: 144px;
--block-padding: 96px;
}
@include mid-break {
--gutter-h: 24px;
--block-padding: 60px;
}
}
/////////////////////////////
// GLOBAL STYLES
/////////////////////////////
* {
box-sizing: border-box;
}
html {
@extend %body;
background: var(--color-white);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-body);
color: var(--color-black);
margin: 0;
}
::selection {
background: var(--color-green);
color: var(--color-black);
}
::-moz-selection {
background: var(--color-green);
color: var(--color-black);
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
@extend %h1;
}
h2 {
@extend %h2;
}
h3 {
@extend %h3;
}
h4 {
@extend %h4;
}
h5 {
@extend %h5;
}
h6 {
@extend %h6;
}
p {
margin: var(--base) 0;
@include mid-break {
margin: calc(var(--base) * .75) 0;
}
}
ul,
ol {
padding-left: var(--base);
margin: 0 0 var(--base);
}
a {
color: currentColor;
&:focus {
opacity: .8;
outline: none;
}
&:active {
opacity: .7;
outline: none;
}
}
svg {
vertical-align: middle;
}

View File

@@ -0,0 +1,10 @@
:root {
--color-red: rgb(255,0,0);
--color-green: rgb(178, 255, 214);
--color-white: rgb(255, 255, 255);
--color-dark-gray: rgb(51,52,52);
--color-mid-gray: rgb(196,196,196);
--color-gray: rgb(212,212,212);
--color-light-gray: rgb(244,244,244);
--color-black: rgb(0, 0, 0);
}

View File

@@ -0,0 +1,2 @@
@forward './queries.scss';
@forward './type.scss';

View File

@@ -0,0 +1,32 @@
$breakpoint-xs-width: 400px;
$breakpoint-s-width: 768px;
$breakpoint-m-width: 1024px;
$breakpoint-l-width: 1440px;
////////////////////////////
// MEDIA QUERIES
/////////////////////////////
@mixin extra-small-break {
@media (max-width: #{$breakpoint-xs-width}) {
@content;
}
}
@mixin small-break {
@media (max-width: #{$breakpoint-s-width}) {
@content;
}
}
@mixin mid-break {
@media (max-width: #{$breakpoint-m-width}) {
@content;
}
}
@mixin large-break {
@media (max-width: #{$breakpoint-l-width}) {
@content;
}
}

View File

@@ -0,0 +1,168 @@
@use 'queries' as *;
/////////////////////////////
// HEADINGS
/////////////////////////////
%h1,
%h2,
%h3,
%h4,
%h5,
%h6 {
font-weight: 700;
}
%h1 {
margin: 50px 0;
font-size: 84px;
line-height: 1;
@include mid-break {
font-size: 70px;
}
@include small-break {
margin: 24px 0;
font-size: 36px;
line-height: 42px;
}
}
%h2 {
margin: 32px 0;
font-size: 56px;
line-height: 1;
@include mid-break {
margin: 36px 0;
font-size: 48px;
}
@include small-break {
margin: 24px 0;
font-size: 28px;
line-height: 32px;
}
}
%h3 {
margin: 28px 0;
font-size: 48px;
line-height: 56px;
@include mid-break {
font-size: 40px;
line-height: 48px;
}
@include small-break {
margin: 24px 0;
font-size: 24px;
line-height: 30px;
}
}
%h4 {
margin: 24px 0;
font-size: 40px;
line-height: 48px;
@include mid-break {
font-size: 33px;
line-height: 36px;
}
@include small-break {
margin: 20px 0;
font-size: 20px;
line-height: 24px;
}
}
%h5 {
margin: 20px 0;
font-size: 32px;
line-height: 42px;
@include mid-break {
font-size: 26px;
line-height: 32px;
}
@include small-break {
margin: 16px 0;
font-size: 18px;
line-height: 24px;
}
}
%h6 {
margin: 20px 0;
font-size: 24px;
line-height: 28px;
@include mid-break {
font-size: 20px;
line-height: 30px;
}
@include small-break {
margin: 16px 0;
font-size: 16px;
line-height: 22px;
}
}
/////////////////////////////
// TYPE STYLES
/////////////////////////////
%body {
font-size: 18px;
line-height: 32px;
@include mid-break {
font-size: 15px;
line-height: 24px;
}
@include small-break {
font-size: 13px;
line-height: 24px;
}
}
%large-body {
font-size: 25px;
line-height: 32px;
@include mid-break {
font-size: 22px;
line-height: 30px;
}
@include small-break {
font-size: 17px;
line-height: 24px;
}
}
%label {
font-size: 16px;
line-height: 24px;
letter-spacing: 3px;
text-transform: uppercase;
@include mid-break {
font-size: 13px;
letter-spacing: 2.75px;
}
@include small-break {
font-size: 12px;
line-height: 18px;
letter-spacing: 2.625px;
}
}

View File

@@ -0,0 +1,17 @@
const redirects = require('./redirects');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: [
'localhost',
process.env.NEXT_PUBLIC_CMS_URL
],
},
redirects,
}
module.exports = nextConfig

View File

@@ -0,0 +1,30 @@
{
"name": "nextjs-redirects-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@faceless-ui/modal": "^2.0.1",
"escape-html": "^1.0.3",
"next": "12.3.1",
"payload-admin-bar": "^1.0.5",
"payload-plugin-nested-pages": "^0.0.4",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-dom": "18.2.0",
"sass": "^1.55.0",
"slate": "^0.84.0"
},
"devDependencies": {
"@types/node": "18.11.3",
"@types/react": "18.0.21",
"eslint": "8.25.0",
"eslint-config-next": "12.3.1",
"typescript": "4.8.4"
}
}

View File

@@ -0,0 +1,9 @@
@import '../css/queries.scss';
.hero {
padding-top: calc(var(--base) * 1.5);
@include mid-break {
padding-top: var(--base);
}
}

View File

@@ -0,0 +1,171 @@
import React, { Fragment } from 'react';
import {
GetStaticProps,
GetStaticPropsContext,
GetStaticPaths
} from 'next';
import { useRouter } from 'next/router';
import { ParsedUrlQuery } from 'querystring';
import type { Page, MainMenu } from '../payload-types';
import { Gutter } from '../components/Gutter';
import { VerticalPadding } from '../components/VerticalPadding';
import RichText from '../components/RichText';
import classes from './[...slug].module.scss';
const Page: React.FC<Page & {
mainMenu: MainMenu
preview?: boolean
}> = (props) => {
const {
title,
richText,
} = props;
const {
isFallback // returned from getStaticPaths, see https://nextjs.org/docs/basic-features/data-fetching#fallback-pages
} = useRouter();
return (
<main>
{isFallback && (
<Gutter>
<VerticalPadding
top='large'
bottom='large'
>
<h3>Loading...</h3>
</VerticalPadding>
</Gutter>
)}
{!isFallback && (
<Fragment>
<Gutter>
<h1 className={classes.hero}>{title}</h1>
<VerticalPadding>
<RichText content={richText} />
</VerticalPadding>
</Gutter>
</Fragment>
)}
</main>
)
}
export default Page;
interface IParams extends ParsedUrlQuery {
slug: string[]
}
// when 'preview' cookies are set in the browser, getStaticProps runs on every request :)
// NOTE: 'slug' is an array (i.e. [...slug].tsx)
export const getStaticProps: GetStaticProps = async (
context: GetStaticPropsContext,
) => {
const {
params
} = context;
let { slug } = params as IParams || {};
if (!slug) slug = ['home'];
let doc = {};
let notFound = false;
const lastSlug = slug[slug.length - 1];
// when previewing, send the payload token to bypass draft access control
const lowerCaseSlug = lastSlug.toLowerCase(); // NOTE: let the url be case insensitive
let pageReq;
let pageData;
pageReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages?where[slug][equals]=${lowerCaseSlug}&depth=2&draft=true`);
if (pageReq.ok) {
pageData = await pageReq.json();
}
if (pageData) {
const { docs } = pageData;
if (docs.length > 0) {
const slugChain = `/${slug.join('/')}`;
// 'slug' is not unique, need to match the correct result to its last-most breadcrumb
const foundDoc = docs.find((doc) => {
const { breadcrumbs } = doc;
const hasBreadcrumbs = breadcrumbs && Array.isArray(breadcrumbs) && breadcrumbs.length > 0;
if (hasBreadcrumbs) {
const lastCrumb = breadcrumbs[breadcrumbs.length - 1];
return lastCrumb.url === slugChain;
}
})
if (foundDoc) {
doc = foundDoc
} else notFound = true
} else notFound = true;
} else notFound = true;
return ({
props: {
...doc,
collection: 'pages'
},
notFound,
})
}
type Path = {
params: {
slug: string[]
}
};
type Paths = Path[];
export const getStaticPaths: GetStaticPaths = async () => {
let paths: Paths = [];
let pagesReq;
let pagesData;
pagesReq = await fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/pages?where[_status][equals]=published&depth=0&limit=300`);
pagesData = await pagesReq.json();
if (pagesReq?.ok) {
const { docs: pages } = pagesData;
if (pages && Array.isArray(pages) && pages.length > 0) {
paths = pages.map((page) => {
const {
slug,
breadcrumbs,
} = page;
let slugs = [slug];
const hasBreadcrumbs = breadcrumbs && Array.isArray(breadcrumbs) && breadcrumbs.length > 0;
if (hasBreadcrumbs) {
slugs = breadcrumbs.map((crumb: any) => {
const { url } = crumb;
let slug;
if (url) {
const split = url.split('/');
slug = split[split.length - 1];
}
return slug;
})
}
return ({ params: { slug: slugs } })
});
}
}
return {
paths,
fallback: true
}
}

View File

@@ -0,0 +1,69 @@
import App, { AppContext, AppProps as NextAppProps } from 'next/app';
import { ModalContainer, ModalProvider } from '@faceless-ui/modal';
import React from 'react';
import { MainMenu } from "../payload-types";
import { Header } from '../components/Header';
import { GlobalsProvider } from '../providers/Globals';
import { CloseModalOnRouteChange } from '../components/CloseModalOnRouteChange';
import { CookiesProvider } from 'react-cookie';
import '../css/app.scss';
export interface IGlobals {
mainMenu: MainMenu,
}
export const getAllGlobals = async (): Promise<IGlobals> => {
const [
mainMenu,
] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_CMS_URL}/api/globals/main-menu?depth=1`).then((res) => res.json()),
]);
return {
mainMenu,
}
}
type AppProps<P = any> = {
pageProps: P;
} & Omit<NextAppProps<P>, "pageProps">;
const PayloadApp = (appProps: AppProps & {
globals: IGlobals,
}): React.ReactElement => {
const {
Component,
pageProps,
globals,
} = appProps;
return (
<CookiesProvider>
<GlobalsProvider {...globals}>
<ModalProvider
classPrefix="form"
transTime={0}
zIndex="var(--modal-z-index)"
>
<CloseModalOnRouteChange />
<Header />
<Component {...pageProps} />
<ModalContainer />
</ModalProvider>
</GlobalsProvider>
</CookiesProvider>
)
}
PayloadApp.getInitialProps = async (appContext: AppContext) => {
const appProps = await App.getInitialProps(appContext);
const globals = await getAllGlobals();
return {
...appProps,
globals
};
};
export default PayloadApp

View File

@@ -0,0 +1,10 @@
import { GetStaticProps } from 'next';
import Page, { getStaticProps as sharedGetStaticProps } from './[...slug]';
export default Page;
export const getStaticProps: GetStaticProps = async (ctx) => {
const func = sharedGetStaticProps.bind(this);
return func(ctx);
};

View File

@@ -0,0 +1,75 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
pages: Page;
users: User;
redirects: Redirect;
};
globals: {
'main-menu': MainMenu;
};
}
export interface Page {
id: string;
title: string;
richText: {
[k: string]: unknown;
}[];
slug?: string;
parent?: string | Page;
breadcrumbs: {
doc?: string | Page;
url?: string;
label?: string;
id?: string;
}[];
_status?: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
}
export interface Redirect {
id: string;
from: string;
to: {
type?: 'reference' | 'custom';
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
};
createdAt: string;
updatedAt: string;
}
export interface MainMenu {
id: string;
navItems: {
link: {
type?: 'reference' | 'custom';
newTab?: boolean;
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
label: string;
};
id?: string;
}[];
}

View File

@@ -0,0 +1,30 @@
import React, { createContext, useContext } from 'react';
import { MainMenu } from '../../payload-types';
export type MainMenuType = MainMenu
export interface IGlobals {
mainMenu: MainMenuType,
}
export const GlobalsContext = createContext<IGlobals>({} as IGlobals);
export const useGlobals = (): IGlobals => useContext(GlobalsContext);
export const GlobalsProvider: React.FC<IGlobals & {
children: React.ReactNode
}> = (props) => {
const {
mainMenu,
children,
} = props;
return (
<GlobalsContext.Provider
value={{
mainMenu,
}}
>
{children}
</GlobalsContext.Provider>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,72 @@
const permalinks = require('./utilities/formatPermalink');
const { formatPermalink } = permalinks;
module.exports = async () => {
const internetExplorerRedirect = {
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
has: [
{
type: 'header',
key: 'user-agent',
value: '(.*Trident.*)', // all ie browsers
},
],
permanent: false,
destination: '/ie-incompatible.html',
};
const redirectsRes = await fetch(`${process.env.NEXT_PUBLIC_CMS_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,
url,
reference,
} = {}
} = doc;
let source = from
.replace(process.env.NEXT_PUBLIC_APP_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_APP_URL, '');
}
if (type === 'reference' && reference?.value?._status === 'published') {
destination = formatPermalink(reference)
}
const redirect = {
source,
destination,
permanent: true,
}
if (source.startsWith('/') && destination && source !== destination) {
return dynamicRedirects.push(redirect)
}
return;
})
}
const redirects = [
internetExplorerRedirect,
...dynamicRedirects
];
return redirects;
}

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,4 @@
export default !!(
(typeof window !== 'undefined'
&& window.document && window.document.createElement)
);

View File

@@ -0,0 +1,32 @@
// cannot use ts here, for nodejs sitemap and redirects module
// this means we have to send through the 'currentCategory' which is a url param not accessible within node
module.exports.formatPermalink = (reference, currentCategory) => {
let permalink = '';
const {
relationTo,
value,
} = reference;
if (typeof value === 'object' && value !== null) {
const {
slug,
breadcrumbs,
categories,
firstCategory
} = value;
// pages could be nested, so use breadcrumbs
if (relationTo === 'pages') {
if (breadcrumbs) {
const { url: lastCrumbURL = '' } = breadcrumbs?.[breadcrumbs.length - 1] || {}; // last crumb
permalink = lastCrumbURL;
} else {
permalink = slug;
}
}
}
return permalink;
}

View File

@@ -0,0 +1,2 @@
export const toKebabCase = (string) => string?.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase();