chore: builds live preview app (#3451)

This commit is contained in:
Jacob Fletcher
2023-10-06 12:32:13 -04:00
committed by GitHub
parent a8ff06a134
commit 5f4c38ce21
115 changed files with 4954 additions and 421 deletions

View File

@@ -42,6 +42,7 @@
"@testing-library/react": "13.4.0",
"@types/jest": "29.5.4",
"@types/node": "20.5.7",
"@types/qs": "6.9.7",
"@types/react": "18.2.15",
"@types/testing-library__jest-dom": "5.14.8",
"copyfiles": "2.4.1",
@@ -64,6 +65,7 @@
"qs": "6.11.2",
"rimraf": "3.0.2",
"shelljs": "0.8.5",
"slate": "0.91.4",
"ts-node": "10.9.1",
"turbo": "^1.10.15",
"typescript": "5.2.2",

View File

@@ -2,6 +2,8 @@
.live-preview-window {
width: 60%;
flex-shrink: 0;
flex-grow: 0;
position: sticky;
top: var(--doc-controls-height);
height: calc(100vh - var(--doc-controls-height));

View File

@@ -17,13 +17,16 @@
}
&__main {
flex-grow: 1;
flex-shrink: 1;
width: 40%;
display: flex;
flex-direction: column;
min-height: 100%;
position: relative;
&--popup-open {
width: 100%;
}
&::after {
content: ' ';
position: absolute;
@@ -54,6 +57,7 @@
&__main {
min-height: initial;
width: 100%;
}
&__form {

View File

@@ -89,7 +89,14 @@ export const LivePreviewView: React.FC<EditViewProps> = (props) => {
.filter(Boolean)
.join(' ')}
>
<div className={`${baseClass}__main`}>
<div
className={[
`${baseClass}__main`,
popupState?.isPopupOpen && `${baseClass}__main--popup-open`,
]
.filter(Boolean)
.join(' ')}
>
<Meta
description={t('editing')}
keywords={`${getTranslation(collection.labels.singular, i18n)}, Payload, CMS`}

9
pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
'@types/node':
specifier: 20.5.7
version: 20.5.7
'@types/qs':
specifier: 6.9.7
version: 6.9.7
'@types/react':
specifier: 18.2.15
version: 18.2.15
@@ -111,6 +114,9 @@ importers:
shelljs:
specifier: 0.8.5
version: 0.8.5
slate:
specifier: 0.91.4
version: 0.91.4
ts-node:
specifier: 10.9.1
version: 10.9.1(@swc/core@1.3.76)(@types/node@20.5.7)(typescript@5.2.2)
@@ -9233,7 +9239,6 @@ packages:
/immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
dev: false
/immutable@4.3.4:
resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==}
@@ -13979,7 +13984,6 @@ packages:
immer: 9.0.21
is-plain-object: 5.0.0
tiny-warning: 1.0.3
dev: false
/slice-ansi@5.0.0:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
@@ -14517,7 +14521,6 @@ packages:
/tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/titleize@3.0.0:
resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}

2
test/live-preview/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,99 @@
import type { Block } from '../../../../packages/payload/src/fields/config/types'
export const Archive: Block = {
slug: 'archive',
labels: {
singular: 'Archive',
plural: 'Archives',
},
fields: [
{
name: 'introContent',
label: 'Intro Content',
type: 'richText',
},
{
name: 'populateBy',
type: 'select',
defaultValue: 'collection',
options: [
{
label: 'Collection',
value: 'collection',
},
{
label: 'Individual Selection',
value: 'selection',
},
],
},
{
type: 'select',
name: 'relationTo',
label: 'Collections To Show',
defaultValue: 'posts',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
options: [
{
label: 'Posts',
value: 'posts',
},
],
},
{
type: 'relationship',
name: 'categories',
label: 'Categories To Show',
relationTo: 'categories',
hasMany: true,
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
},
{
type: 'number',
name: 'limit',
label: 'Limit',
defaultValue: 10,
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
step: 1,
},
},
{
type: 'relationship',
name: 'selectedDocs',
label: 'Selection',
relationTo: ['posts'],
hasMany: true,
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'selection',
},
},
{
type: 'relationship',
name: 'populatedDocs',
label: 'Populated Docs',
relationTo: ['posts'],
hasMany: true,
admin: {
disabled: true,
description: 'This field is auto-populated after-read',
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
},
{
type: 'number',
name: 'populatedDocsTotal',
label: 'Populated Docs Total',
admin: {
step: 1,
disabled: true,
description: 'This field is auto-populated after-read',
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
},
],
}

View File

@@ -0,0 +1,26 @@
import type { Block } from '../../../../packages/payload/src/fields/config/types'
import { invertBackground } from '../../fields/invertBackground'
import linkGroup from '../../fields/linkGroup'
export const CallToAction: Block = {
slug: 'cta',
labels: {
singular: 'Call to Action',
plural: 'Calls to Action',
},
fields: [
invertBackground,
{
name: 'richText',
label: 'Rich Text',
type: 'richText',
},
linkGroup({
appearances: ['primary', 'secondary'],
overrides: {
maxRows: 2,
},
}),
],
}

View File

@@ -0,0 +1,58 @@
import type { Block, Field } from '../../../../packages/payload/src/fields/config/types'
import { invertBackground } from '../../fields/invertBackground'
import link from '../../fields/link'
const columnFields: Field[] = [
{
name: 'size',
type: 'select',
defaultValue: 'oneThird',
options: [
{
value: 'oneThird',
label: 'One Third',
},
{
value: 'half',
label: 'Half',
},
{
value: 'twoThirds',
label: 'Two Thirds',
},
{
value: 'full',
label: 'Full',
},
],
},
{
name: 'richText',
label: 'Rich Text',
type: 'richText',
},
{
name: 'enableLink',
type: 'checkbox',
},
link({
overrides: {
admin: {
condition: (_, { enableLink }) => Boolean(enableLink),
},
},
}),
]
export const Content: Block = {
slug: 'content',
fields: [
invertBackground,
{
name: 'columns',
type: 'array',
fields: columnFields,
},
],
}

View File

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

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
const Categories: CollectionConfig = {
slug: 'categories',
admin: {
useAsTitle: 'title',
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
}
export default Categories

View File

@@ -0,0 +1,20 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const Media: CollectionConfig = {
slug: 'media',
upload: true,
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'richText',
},
],
}

View File

@@ -0,0 +1,94 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import { Archive } from '../blocks/ArchiveBlock'
import { CallToAction } from '../blocks/CallToAction'
import { Content } from '../blocks/Content'
import { MediaBlock } from '../blocks/MediaBlock'
import { hero } from '../fields/hero'
export const pagesSlug = 'pages'
export const Pages: CollectionConfig = {
slug: pagesSlug,
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
livePreview: {
url: 'http://localhost:3001',
breakpoints: [
{
label: 'Mobile',
name: 'mobile',
width: 375,
height: 667,
},
// {
// label: 'Desktop',
// name: 'desktop',
// width: 1440,
// height: 900,
// },
],
},
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
},
fields: [
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'title',
type: 'text',
required: true,
},
{
type: 'tabs',
tabs: [
{
label: 'Hero',
fields: [hero],
},
{
label: 'Content',
fields: [
{
name: 'layout',
type: 'blocks',
required: true,
blocks: [CallToAction, Content, MediaBlock, Archive],
},
],
},
],
},
{
name: 'meta',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
},
],
}

View File

@@ -0,0 +1,107 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import { Archive } from '../blocks/ArchiveBlock'
import { CallToAction } from '../blocks/CallToAction'
import { Content } from '../blocks/Content'
import { MediaBlock } from '../blocks/MediaBlock'
import { hero } from '../fields/hero'
export const postsSlug = 'posts'
export const Posts: CollectionConfig = {
slug: postsSlug,
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
livePreview: {
url: 'http://localhost:3001',
breakpoints: [
{
label: 'Mobile',
name: 'mobile',
width: 375,
height: 667,
},
// {
// label: 'Desktop',
// name: 'desktop',
// width: 1440,
// height: 900,
// },
],
},
useAsTitle: 'title',
defaultColumns: ['id', 'title', 'slug', 'createdAt'],
},
fields: [
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'title',
type: 'text',
required: true,
},
{
type: 'tabs',
tabs: [
{
label: 'Hero',
fields: [hero],
},
{
label: 'Content',
fields: [
{
name: 'layout',
type: 'blocks',
required: true,
blocks: [CallToAction, Content, MediaBlock, Archive],
},
{
name: 'relatedPosts',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
filterOptions: ({ id }) => {
return {
id: {
not_in: [id],
},
}
},
},
],
},
],
},
{
name: 'meta',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'textarea',
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
],
},
],
}

View File

@@ -1,15 +1,23 @@
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
import { devUser } from '../credentials'
import Categories from './collections/Categories'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Posts, postsSlug } from './collections/Posts'
import { Footer } from './globals/Footer'
import { Header } from './globals/Header'
import { footer } from './seed/footer'
import { header } from './seed/header'
import { home } from './seed/home'
import { post1 } from './seed/post-1'
import { post2 } from './seed/post-2'
import { post3 } from './seed/post-3'
import { postsPage } from './seed/posts-page'
export interface Post {
createdAt: Date
description: string
id: string
title: string
updatedAt: Date
}
export const pagesSlug = 'pages'
export const slug = 'pages'
export default buildConfigWithDefaults({
admin: {},
cors: ['http://localhost:3001'],
@@ -23,105 +31,12 @@ export default buildConfigWithDefaults({
},
fields: [],
},
{
slug,
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
livePreview: {
url: 'http://localhost:3001',
breakpoints: [
{
label: 'Mobile',
name: 'mobile',
width: 375,
height: 667,
},
// {
// label: 'Desktop',
// name: 'desktop',
// width: 1440,
// height: 900,
// },
],
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'layout',
type: 'blocks',
blocks: [
{
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Hero',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
required: true,
},
],
},
],
},
{
name: 'featuredPosts',
type: 'relationship',
relationTo: 'posts',
hasMany: true,
},
],
},
{
slug: 'posts',
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
},
Pages,
Posts,
Categories,
Media,
],
globals: [Header, Footer],
onInit: async (payload) => {
await payload.create({
collection: 'users',
@@ -131,28 +46,54 @@ export default buildConfigWithDefaults({
},
})
const post1 = await payload.create({
collection: 'posts',
const media = await payload.create({
collection: 'media',
filePath: path.resolve(__dirname, 'image-1.jpg'),
data: {
title: 'Post 1',
alt: 'Image 1',
},
})
const [post1Doc, post2Doc, post3Doc] = await Promise.all([
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post1).replace(/\{\{IMAGE\}\}/g, media.id)),
}),
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post2).replace(/\{\{IMAGE\}\}/g, media.id)),
}),
await payload.create({
collection: postsSlug,
data: JSON.parse(JSON.stringify(post3).replace(/\{\{IMAGE\}\}/g, media.id)),
}),
])
const postsPageDoc = await payload.create({
collection: pagesSlug,
data: JSON.parse(JSON.stringify(postsPage).replace(/\{\{IMAGE\}\}/g, media.id)),
})
await payload.create({
collection: slug,
data: {
title: 'Hello, world!',
description: 'This is an example of live preview.',
slug: 'home',
layout: [
{
blockType: 'hero',
title: 'Hello, world!',
description: 'This is an example of live preview.',
},
],
featuredPosts: [post1.id],
},
collection: pagesSlug,
data: JSON.parse(
JSON.stringify(home)
.replace(/\{\{MEDIA_ID\}\}/g, media.id)
.replace(/\{\{POSTS_PAGE_ID\}\}/g, postsPageDoc.id)
.replace(/\{\{POST_1_ID\}\}/g, post1Doc.id)
.replace(/\{\{POST_2_ID\}\}/g, post2Doc.id)
.replace(/\{\{POST_3_ID\}\}/g, post3Doc.id),
),
})
await payload.updateGlobal({
slug: 'header',
data: JSON.parse(JSON.stringify(header).replace(/\{\{POSTS_PAGE_ID\}\}/g, postsPageDoc.id)),
})
await payload.updateGlobal({
slug: 'footer',
data: JSON.parse(JSON.stringify(footer)),
})
},
})

View File

@@ -0,0 +1,44 @@
import type { Field } from '../../../packages/payload/src/fields/config/types'
export const hero: Field = {
name: 'hero',
label: false,
type: 'group',
fields: [
{
type: 'select',
name: 'type',
label: 'Type',
required: true,
defaultValue: 'lowImpact',
options: [
{
label: 'None',
value: 'none',
},
{
label: 'High Impact',
value: 'highImpact',
},
{
label: 'Low Impact',
value: 'lowImpact',
},
],
},
{
name: 'richText',
label: 'Rich Text',
type: 'richText',
},
{
name: 'media',
type: 'upload',
relationTo: 'media',
required: true,
admin: {
condition: (_, { type } = {}) => ['highImpact'].includes(type),
},
},
],
}

View File

@@ -0,0 +1,6 @@
import type { CheckboxField } from '../../../packages/payload/src/fields/config/types'
export const invertBackground: CheckboxField = {
name: 'invertBackground',
type: 'checkbox',
}

View File

@@ -0,0 +1,150 @@
import type { Field } from '../../../packages/payload/src/fields/config/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 = 'default' | 'primary' | 'secondary'
type LinkType = (options?: {
appearances?: LinkAppearances[] | false
disableLabel?: boolean
overrides?: Record<string, unknown>
}) => Field
const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
const 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',
},
},
},
],
},
],
}
const 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.map((linkType) => ({
...linkType,
admin: {
...linkType.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,26 @@
import type { ArrayField, Field } from '../../../packages/payload/src/fields/config/types'
import type { LinkAppearances } from './link'
import deepMerge from '../utilities/deepMerge'
import link from './link'
type LinkGroupType = (options?: {
appearances?: LinkAppearances[] | false
overrides?: Partial<ArrayField>
}) => Field
const linkGroup: LinkGroupType = ({ overrides = {}, appearances } = {}) => {
const generatedLinkGroup: Field = {
name: 'links',
type: 'array',
fields: [
link({
appearances,
}),
],
}
return deepMerge(generatedLinkGroup, overrides)
}
export default linkGroup

View File

@@ -0,0 +1,18 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
import link from '../fields/link'
export const Footer: GlobalConfig = {
slug: 'footer',
access: {
read: () => true,
},
fields: [
{
name: 'navItems',
type: 'array',
maxRows: 6,
fields: [link()],
},
],
}

View File

@@ -0,0 +1,18 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
import link from '../fields/link'
export const Header: GlobalConfig = {
slug: 'header',
access: {
read: () => true,
},
fields: [
{
name: 'navItems',
type: 'array',
maxRows: 6,
fields: [link()],
},
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -0,0 +1,54 @@
import type { Page } from './payload-types'
import payload from '../../packages/payload/src'
import { mergeLivePreviewData } from '../../packages/payload/src/admin/components/views/LivePreview/useLivePreview/mergeData'
import { fieldSchemaToJSON } from '../../packages/payload/src/utilities/fieldSchemaToJSON'
import { initPayloadTest } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
import { Pages } from './collections/Pages'
import configPromise, { pagesSlug } from './config'
require('isomorphic-fetch')
let client
let serverURL
describe('Collections - Live Preview', () => {
beforeAll(async () => {
const { serverURL: incomingServerURL } = await initPayloadTest({
__dirname,
init: { local: false },
})
serverURL = incomingServerURL
const config = await configPromise
client = new RESTClient(config, { serverURL, defaultSlug: pagesSlug })
await client.login()
})
it('merges live preview data', async () => {
const testPage = await payload.create({
collection: pagesSlug,
data: {
slug: 'home',
title: 'Test Page',
},
})
expect(testPage?.id).toBeDefined()
const pageEdits: Page = {
title: 'Test Page (Changed)',
} as Page
const mergedData = await mergeLivePreviewData<Page>({
depth: 1,
existingData: testPage,
fieldSchema: fieldSchemaToJSON(Pages.fields),
incomingData: pageEdits,
serverURL,
})
expect(mergedData.title).toEqual(pageEdits.title)
})
})

View File

@@ -0,0 +1,29 @@
'use client'
import { Page as PageType } from '@/payload-types'
import { useLivePreview } from '../../../../../../packages/live-preview-react'
import React from 'react'
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
import { Hero } from '@/app/_components/Hero'
import { Blocks } from '@/app/_components/Blocks'
export const PageClient: React.FC<{
page: PageType
}> = ({ page: initialPage }) => {
const { data } = useLivePreview<PageType>({
initialData: initialPage,
serverURL: PAYLOAD_SERVER_URL,
})
return (
<React.Fragment>
<Hero {...data.hero} />
<Blocks
blocks={data.layout}
disableTopPadding={
!data.hero || data.hero?.type === 'none' || data.hero?.type === 'lowImpact'
}
/>
</React.Fragment>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { notFound } from 'next/navigation'
import { Page } from '../../../payload-types'
import { fetchDocs } from '@/app/_api/fetchDocs'
import { fetchDoc } from '@/app/_api/fetchDoc'
import { PageClient } from './page.client'
export default async function Page({ params: { slug = 'home' } }) {
let page: Page | null = null
try {
page = await fetchDoc<Page>({
collection: 'pages',
slug,
})
} catch (error) {
console.error(error)
}
if (!page) {
return notFound()
}
return <PageClient page={page} />
}
export async function generateStaticParams() {
try {
const pages = await fetchDocs<Page>('pages')
return pages?.map(({ slug }) => slug)
} catch (error) {
return []
}
}

View File

@@ -0,0 +1,92 @@
import React from 'react'
import { notFound } from 'next/navigation'
import { Post } from '../../../../payload-types'
import { fetchDoc } from '../../../_api/fetchDoc'
import { fetchDocs } from '../../../_api/fetchDocs'
import { Blocks } from '../../../_components/Blocks'
import { PostHero } from '../../../_heros/PostHero'
export default async function Post(args: {
params: {
slug: string
}
}) {
const {
params: { slug = 'home' },
} = args
let post: Post | null = null
try {
post = await fetchDoc<Post>({
collection: 'posts',
slug,
})
} catch (error) {
console.error(error) // eslint-disable-line no-console
}
if (!post) {
notFound()
}
const { layout, relatedPosts } = post
return (
<React.Fragment>
<PostHero post={post} />
<Blocks blocks={layout} />
<Blocks
disableTopPadding
blocks={[
{
blockType: 'relatedPosts',
blockName: 'Related Posts',
relationTo: 'posts',
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',
url: `/admin/collections/posts/${post.id}`,
children: [
{
text: 'navigate to the admin dashboard',
},
],
},
{
text: '.',
},
],
},
],
docs: relatedPosts,
},
]}
/>
</React.Fragment>
)
}
export async function generateStaticParams() {
try {
const posts = await fetchDocs<Post>('posts')
return posts?.map(({ slug }) => slug)
} catch (error) {
return []
}
}

View File

@@ -0,0 +1,30 @@
import type { Config } from '../../payload-types'
import { PAYLOAD_SERVER_URL } from './serverURL'
export const fetchDoc = async <T>(args: {
collection: keyof Config['collections']
slug?: string
id?: string
}): Promise<T> => {
const { collection, slug, id } = args || {}
const doc: T = await fetch(
`${PAYLOAD_SERVER_URL}/api/${collection}${id ? `/${id}` : ''}${
slug ? `?where[slug][equals]=${slug}` : ''
}`,
{
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
},
)
?.then((res) => res.json())
?.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching doc')
return res?.docs?.[0]
})
return doc
}

View File

@@ -0,0 +1,20 @@
import type { Config } from '../../payload-types'
import { PAYLOAD_SERVER_URL } from './serverURL'
export const fetchDocs = async <T>(collection: keyof Config['collections']): Promise<T[]> => {
const docs: T[] = await fetch(`${PAYLOAD_SERVER_URL}/api/${collection}?limit=100`, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
})
?.then((res) => res.json())
?.then((res) => {
if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching docs')
return res?.docs
})
return docs
}

View File

@@ -0,0 +1,24 @@
import type { Footer } from '../../payload-types'
import { PAYLOAD_SERVER_URL } from './serverURL'
export async function fetchFooter(): Promise<Footer> {
if (!PAYLOAD_SERVER_URL) throw new Error('PAYLOAD_SERVER_URL not found')
const footer = await fetch(`${PAYLOAD_SERVER_URL}/api/globals/footer`, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
})
.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
})
return footer
}

View File

@@ -0,0 +1,24 @@
import type { Header } from '../../payload-types'
import { PAYLOAD_SERVER_URL } from './serverURL'
export async function fetchHeader(): Promise<Header> {
if (!PAYLOAD_SERVER_URL) throw new Error('PAYLOAD_SERVER_URL not found')
const header = await fetch(`${PAYLOAD_SERVER_URL}/api/globals/header`, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
})
?.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
})
return header
}

View File

@@ -0,0 +1 @@
export const PAYLOAD_SERVER_URL = 'http://localhost:3000'

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import type { Page } from '../../../payload-types'
export type ArchiveBlockProps = Extract<Page['layout'][0], { blockType: 'archive' }>

View File

@@ -0,0 +1,47 @@
@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(--color-base-100);
color: var(--color-base-1000);
}
.invert {
background-color: var(--color-base-1000);
color: var(--color-base-0);
}
.wrap {
display: flex;
gap: $spacer-h;
align-items: center;
@include mid-break {
flex-direction: column;
align-items: flex-start;
}
}
.content {
flex-grow: 1;
}
.linkGroup {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
flex-shrink: 0;
> * {
margin-bottom: calc(var(--base) / 2);
&:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -0,0 +1,38 @@
import React from 'react'
import { Page } from '../../../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
}
> = ({ links, richText, invertBackground }) => {
return (
<Gutter>
<VerticalPadding
className={[classes.callToAction, invertBackground && classes.invert]
.filter(Boolean)
.join(' ')}
>
<div className={classes.wrap}>
<div className={classes.content}>
<RichText className={classes.richText} content={richText} />
</div>
<div className={classes.linkGroup}>
{(links || []).map(({ link }, i) => {
return <CMSLink key={i} {...link} invert={invertBackground} />
})}
</div>
</div>
</VerticalPadding>
</Gutter>
)
}

View File

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

View File

@@ -0,0 +1,37 @@
import React from 'react'
import { 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, richText, link, size } = col
return (
<div key={index} className={[classes.column, classes[`column--${size}`]].join(' ')}>
<RichText content={richText} />
{enableLink && <CMSLink className={classes.link} {...link} />}
</div>
)
})}
</div>
</Gutter>
)
}

View File

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

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { StaticImageData } from 'next/image'
import { 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' }> & {
staticImage?: StaticImageData
id?: string
}
export const MediaBlock: React.FC<Props> = (props) => {
const { media, position = 'default', staticImage } = props
let caption
if (media && typeof media === 'object') caption = media.caption
return (
<div className={classes.mediaBlock}>
{position === 'fullscreen' && (
<div className={classes.fullscreen}>
<Media resource={media} src={staticImage} />
</div>
)}
{position === 'default' && (
<Gutter>
<Media resource={media} src={staticImage} />
</Gutter>
)}
{caption && (
<Gutter className={classes.caption}>
<RichText content={caption} />
</Gutter>
)}
</div>
)
}

View File

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

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { Post } from '../../../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 = {
blockType: 'relatedPosts'
blockName: string
introContent?: any
docs?: (string | Post)[]
relationTo: 'posts'
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
const { introContent, docs, 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
key={index}
className={[
classes.column,
docs.length === 2 && classes['cols-half'],
docs.length >= 3 && classes['cols-thirds'],
]
.filter(Boolean)
.join(' ')}
>
<Card relationTo={relationTo} doc={doc} showCategories />
</div>
)
})}
</div>
</Gutter>
</div>
)
}

View File

@@ -0,0 +1,3 @@
.invert {
background-color: var(--color-base-750);
}

View File

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

View File

@@ -0,0 +1,81 @@
import React, { Fragment } from 'react'
import { Page } from '../../../payload-types.js'
import { ArchiveBlock } from '../../_blocks/ArchiveBlock'
import { CallToActionBlock } from '../../_blocks/CallToAction'
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, VerticalPaddingOptions } from '../VerticalPadding'
const blockComponents = {
cta: CallToActionBlock,
content: ContentBlock,
mediaBlock: MediaBlock,
archive: ArchiveBlock,
relatedPosts: RelatedPosts,
}
export const Blocks: React.FC<{
blocks: (Page['layout'][0] | RelatedPostsProps)[]
disableTopPadding?: boolean
}> = (props) => {
const { disableTopPadding, blocks } = 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 key={index} invert={blockIsInverted}>
<VerticalPadding top={paddingTop} bottom={paddingBottom}>
{/* @ts-expect-error */}
<Block id={toKebabCase(blockName)} {...block} />
</VerticalPadding>
</BackgroundColor>
)
}
}
return null
})}
</Fragment>
)
}
return null
}

View File

@@ -0,0 +1,72 @@
@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(--color-base-1000);
color: var(--color-base-0);
}
.appearance--secondary {
background-color: transparent;
box-shadow: inset 0 0 0 1px var(--color-base-1000);
}
.primary--invert {
background-color: var(--color-base-0);
color: var(--color-base-1000);
}
.secondary--invert {
background-color: var(--color-base-1000);
box-shadow: inset 0 0 0 1px var(--color-base-0);
}
.appearance--default {
padding: 0;
color: var(--theme-text);
}
.appearance--none {
padding: 0;
color: var(--theme-text);
&:local() {
.label {
text-transform: none;
line-height: inherit;
font-size: inherit;
}
}
}

View File

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

View File

@@ -0,0 +1,106 @@
@import '../../_css/common';
.card {
border: 1px var(--color-base-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(--color-base-50);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.actions {
display: flex;
align-items: center;
@include mid-break {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,86 @@
import React, { Fragment } from 'react'
import Link from 'next/link'
import { Post } from '../../../payload-types'
import { Media } from '../Media'
import classes from './index.module.scss'
export const Card: React.FC<{
alignItems?: 'center'
className?: string
showCategories?: boolean
hideImagesOnMobile?: boolean
title?: string
relationTo?: 'posts'
doc?: Post
orientation?: 'horizontal' | 'vertical'
}> = (props) => {
const {
relationTo,
showCategories,
title: titleFromProps,
doc,
className,
orientation = 'vertical',
} = props
const { slug, title, categories, meta } = doc || {}
const { description, image: metaImage } = meta || {}
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
const titleToUse = titleFromProps || title
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
const href = `/${relationTo}/${slug}`
return (
<div
className={[classes.card, className, orientation && classes[orientation]]
.filter(Boolean)
.join(' ')}
>
<Link href={href} className={classes.mediaWrapper}>
{!metaImage && <div className={classes.placeholder}>No image</div>}
{metaImage && typeof metaImage !== 'string' && (
<Media imgClassName={classes.image} resource={metaImage} fill />
)}
</Link>
<div className={classes.content}>
{showCategories && hasCategories && (
<div className={classes.leader}>
{showCategories && hasCategories && (
<div>
{categories?.map((category, index) => {
const titleFromCategory = typeof category === 'string' ? category : category.title
const categoryTitle = titleFromCategory || 'Untitled category'
const isLast = index === categories.length - 1
return (
<Fragment key={index}>
{categoryTitle}
{!isLast && <Fragment>, &nbsp;</Fragment>}
</Fragment>
)
})}
</div>
)}
</div>
)}
{titleToUse && (
<h4 className={classes.title}>
<Link href={href} className={classes.titleLink}>
{titleToUse}
</Link>
</h4>
)}
{description && (
<div className={classes.body}>
{description && <p className={classes.description}>{sanitizedDescription}</p>}
</div>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
'use client'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import qs from 'qs'
import { Post } from '../../../../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'
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
type Result = {
totalDocs: number
docs: (Post | string)[]
page: number
totalPages: number
hasPrevPage: boolean
hasNextPage: boolean
nextPage: number
prevPage: number
}
export type Props = Omit<ArchiveBlockProps, 'blockType'> & {
className?: string
showPageRange?: boolean
onResultChange?: (result: Result) => void // eslint-disable-line no-unused-vars
sort?: string
}
export const CollectionArchiveByCollection: React.FC<Props> = (props) => {
const {
className,
relationTo,
showPageRange,
onResultChange,
sort = '-createdAt',
limit = 10,
populatedDocs,
populatedDocsTotal,
categories: catsFromProps,
} = props
const [results, setResults] = useState<Result>({
totalDocs: typeof populatedDocsTotal === 'number' ? populatedDocsTotal : 0,
docs: populatedDocs?.map((doc) => doc.value) || [],
page: 1,
totalPages: 1,
hasPrevPage: false,
hasNextPage: false,
prevPage: 1,
nextPage: 1,
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const scrollRef = useRef<HTMLDivElement>(null)
const hasHydrated = useRef(false)
const [page, setPage] = useState(1)
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
// 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) {
setIsLoading(true)
}
}, 500)
const searchQuery = qs.stringify(
{
sort,
where: {
...(catsFromProps && catsFromProps?.length > 0
? {
categories: {
in:
typeof catsFromProps === 'string'
? [catsFromProps]
: catsFromProps
.map((cat) => (typeof cat === 'object' && cat !== null ? cat.id : cat))
.join(','),
},
}
: {}),
},
limit,
page,
depth: 1,
},
{ encode: false },
)
const makeRequest = async () => {
try {
const req = await fetch(`${PAYLOAD_SERVER_URL}/api/${relationTo}?${searchQuery}`)
const json = await req.json()
clearTimeout(timer)
hasHydrated.current = true
const { docs } = json as { docs: Post[] }
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.`)
}
}
makeRequest()
return () => {
if (timer) clearTimeout(timer)
}
}, [page, catsFromProps, relationTo, onResultChange, sort, limit])
return (
<div className={[classes.collectionArchive, className].filter(Boolean).join(' ')}>
<div ref={scrollRef} className={classes.scrollRef} />
{!isLoading && error && <Gutter>{error}</Gutter>}
<Fragment>
{showPageRange !== false && (
<Gutter>
<div className={classes.pageRange}>
<PageRange
totalDocs={results.totalDocs}
currentPage={results.page}
collection={relationTo}
limit={limit}
/>
</div>
</Gutter>
)}
<Gutter>
<div className={classes.grid}>
{results.docs?.map((result, index) => {
if (typeof result === 'string') {
return null
}
return (
<div key={index} className={classes.column}>
<Card relationTo="posts" doc={result} showCategories />
</div>
)
})}
</div>
{results.totalPages > 1 && (
<Pagination
className={classes.pagination}
page={results.page}
totalPages={results.totalPages}
onClick={setPage}
/>
)}
</Gutter>
</Fragment>
</div>
)
}

View File

@@ -0,0 +1,25 @@
@import '../../../_css/common';
.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;
}
}

View File

@@ -0,0 +1,42 @@
'use client'
import React, { Fragment } from 'react'
import type { ArchiveBlockProps } from '../../../_blocks/ArchiveBlock/types'
import { Card } from '../../Card'
import { Gutter } from '../../Gutter'
import classes from './index.module.scss'
export type Props = {
className?: string
selectedDocs?: ArchiveBlockProps['selectedDocs']
}
export const CollectionArchiveBySelection: React.FC<Props> = (props) => {
const { className, selectedDocs } = props
const result = selectedDocs?.map((doc) => doc.value)
return (
<div className={[classes.collectionArchive, className].filter(Boolean).join(' ')}>
<Fragment>
<Gutter>
<div className={classes.grid}>
{result?.map((result, index) => {
if (typeof result === 'string') {
return null
}
return (
<div key={index} className={classes.column}>
<Card relationTo="posts" doc={result} showCategories />
</div>
)
})}
</div>
</Gutter>
</Fragment>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
import type { ArchiveBlockProps } from '../../_blocks/ArchiveBlock/types'
import { CollectionArchiveBySelection } from './PopulateBySelection'
import { CollectionArchiveByCollection } from './PopulateByCollection'
export type Props = Omit<ArchiveBlockProps, 'blockType'> & {
className?: string
sort?: string
}
export const CollectionArchive: React.FC<Props> = (props) => {
const { className, populateBy, selectedDocs } = props
if (populateBy === 'selection') {
return <CollectionArchiveBySelection selectedDocs={selectedDocs} className={className} />
}
if (populateBy === 'collection') {
return <CollectionArchiveByCollection {...props} className={className} />
}
return null
}

View File

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

View File

@@ -0,0 +1,40 @@
import React from 'react'
import Link from 'next/link'
import { fetchFooter } from '../../_api/fetchFooter'
import { Gutter } from '../Gutter'
import { CMSLink } from '../Link'
import classes from './index.module.scss'
export async function Footer() {
const footer = await fetchFooter()
const navItems = footer?.navItems || []
return (
<footer className={classes.footer}>
<Gutter className={classes.wrap}>
<Link href="/">
<picture>
<img
className={classes.logo}
alt="Payload Logo"
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
/>
</picture>
</Link>
<nav className={classes.nav}>
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} />
})}
<Link href="/admin">Admin</Link>
<Link href="https://github.com/payloadcms/payload/tree/master/templates/ecommerce">
Source Code
</Link>
<Link href="https://github.com/payloadcms/payload">Payload</Link>
</nav>
</Gutter>
</footer>
)
}

View File

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

View File

@@ -0,0 +1,33 @@
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={[
classes.gutter,
left && classes.gutterLeft,
right && classes.gutterRight,
className,
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
)
})
Gutter.displayName = 'Gutter'

View File

@@ -0,0 +1,12 @@
@use '../../../_css/queries.scss' as *;
.nav {
display: flex;
gap: calc(var(--base) / 4) var(--base);
align-items: center;
flex-wrap: wrap;
> * {
text-decoration: none;
}
}

View File

@@ -0,0 +1,20 @@
'use client'
import React from 'react'
import { Header as HeaderType } from '../../../../payload-types'
import { CMSLink } from '../../Link'
import classes from './index.module.scss'
export const HeaderNav: React.FC<{ header: HeaderType }> = ({ header }) => {
const navItems = header?.navItems || []
return (
<nav className={classes.nav}>
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} />
})}
</nav>
)
}

View File

@@ -0,0 +1,16 @@
@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;
}

View File

@@ -0,0 +1,31 @@
{
/* eslint-disable @next/next/no-img-element */
}
import React from 'react'
import Link from 'next/link'
import { Gutter } from '../Gutter'
import { HeaderNav } from './Nav'
import classes from './index.module.scss'
import { fetchHeader } from '@/app/_api/fetchHeader'
export async function Header() {
const header = await fetchHeader()
return (
<header className={classes.header}>
<Gutter className={classes.wrap}>
<Link href="/">
<img
className={classes.logo}
alt="Payload Logo"
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
/>
</Link>
<HeaderNav header={header} />
</Gutter>
</header>
)
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import { Page } from '../../../payload-types'
import { HighImpactHero } from '../../_heros/HighImpact'
import { LowImpactHero } from '../../_heros/LowImpact'
const heroes = {
highImpact: HighImpactHero,
lowImpact: LowImpactHero,
}
export const Hero: React.FC<Page['hero']> = (props) => {
const { type } = props || {}
if (!type || type === 'none') return null
const HeroToRender = heroes[type]
if (!HeroToRender) return null
return <HeroToRender {...props} />
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import Link from 'next/link'
import { Page } from '../../../payload-types'
import { Button, Props as ButtonProps } from '../Button'
type CMSLinkType = {
type?: 'custom' | 'reference'
url?: string
newTab?: boolean
reference?: {
value: string | Page
relationTo: 'pages' | 'posts'
}
label?: string
appearance?: ButtonProps['appearance']
children?: React.ReactNode
className?: string
invert?: ButtonProps['invert']
}
export const CMSLink: React.FC<CMSLinkType> = ({
type,
url,
newTab,
reference,
label,
appearance,
children,
className,
invert,
}) => {
const href =
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
? `/${reference.value.slug}`
: url
if (!href) return null
if (!appearance) {
const newTabProps = newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}
if (href || url) {
return (
<Link {...newTabProps} href={href || url || ''} className={className}>
{label && label}
{children && children}
</Link>
)
}
}
return (
<Button
className={className}
newTab={newTab}
href={href}
appearance={appearance}
label={label}
invert={invert}
/>
)
}

View File

@@ -0,0 +1,7 @@
.placeholder-color-light {
background-color: rgba(0, 0, 0, 0.05);
}
.placeholder {
background-color: var(--color-base-50);
}

View File

@@ -0,0 +1,76 @@
'use client'
import React from 'react'
import NextImage, { StaticImageData } from 'next/image'
import cssVariables from '../../../cssVariables'
import { Props as MediaProps } from '../types'
import classes from './index.module.scss'
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
const { breakpoints } = cssVariables
export const Image: React.FC<MediaProps> = (props) => {
const {
imgClassName,
onClick,
onLoad: onLoadFromProps,
resource,
priority,
fill,
src: srcFromProps,
alt: altFromProps,
} = props
const [isLoading, setIsLoading] = React.useState(true)
let width: number | undefined
let height: number | undefined
let alt = altFromProps
let src: StaticImageData | string = srcFromProps || ''
if (!src && resource && typeof resource !== 'string') {
const {
width: fullWidth,
height: fullHeight,
filename: fullFilename,
alt: altFromResource,
} = resource
width = fullWidth
height = fullHeight
alt = altFromResource
const filename = fullFilename
src = `${PAYLOAD_SERVER_URL}/media/${filename}`
}
// NOTE: this is used by the browser to determine which image to download at different screen sizes
const sizes = Object.entries(breakpoints)
.map(([, value]) => `(max-width: ${value}px) ${value}px`)
.join(', ')
return (
<NextImage
className={[isLoading && classes.placeholder, classes.image, imgClassName]
.filter(Boolean)
.join(' ')}
src={src}
alt={alt || ''}
onClick={onClick}
onLoad={() => {
setIsLoading(false)
if (typeof onLoadFromProps === 'function') {
onLoadFromProps()
}
}}
fill={fill}
width={!fill ? width : undefined}
height={!fill ? height : undefined}
sizes={sizes}
priority={priority}
/>
)
}

View File

@@ -0,0 +1,11 @@
.video {
max-width: 100%;
width: 100%;
background-color: var(--color-base-50);
}
.cover {
object-fit: cover;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,46 @@
'use client'
import React, { useEffect, useRef } from 'react'
import { Props as MediaProps } from '../types'
import classes from './index.module.scss'
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
export const Video: React.FC<MediaProps> = (props) => {
const { videoClassName, resource, onClick } = props
const videoRef = useRef<HTMLVideoElement>(null)
// const [showFallback] = useState<boolean>()
useEffect(() => {
const { current: video } = videoRef
if (video) {
video.addEventListener('suspend', () => {
// setShowFallback(true);
// console.warn('Video was suspended, rendering fallback image.')
})
}
}, [])
if (resource && typeof resource !== 'string') {
const { filename } = resource
return (
<video
playsInline
autoPlay
muted
loop
controls={false}
className={[classes.video, videoClassName].filter(Boolean).join(' ')}
onClick={onClick}
ref={videoRef}
>
<source src={`${PAYLOAD_SERVER_URL}/media/${filename}`} />
</video>
)
}
return null
}

View File

@@ -0,0 +1,28 @@
import React, { ElementType, Fragment } from 'react'
import { Image } from './Image'
import { Props } from './types'
import { Video } from './Video'
export const Media: React.FC<Props> = (props) => {
const { className, resource, htmlElement = 'div' } = props
const isVideo = typeof resource !== 'string' && resource?.mimeType?.includes('video')
const Tag = (htmlElement as ElementType) || Fragment
return (
<Tag
{...(htmlElement !== null
? {
className,
}
: {})}
>
{isVideo ? (
<Video {...props} />
) : (
<Image {...props} /> // eslint-disable-line
)}
</Tag>
)
}

View File

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

View File

@@ -0,0 +1,21 @@
@import '../../_css/common';
.pageRange {
display: flex;
align-items: center;
font-weight: 600;
}
.content {
display: flex;
align-items: center;
margin: 0 var(--base(0.5));
}
.divider {
margin: 0 2px;
}
.hyperlink {
display: flex;
}

View File

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

View File

@@ -0,0 +1,29 @@
@import '../../_css/type.scss';
.pagination {
@extend %label;
display: flex;
align-items: center;
gap: calc(var(--base) / 2);
}
.button {
all: unset;
cursor: pointer;
position: relative;
display: flex;
padding: calc(var(--base) / 2);
color: var(--color-base-500);
border: 1px solid var(--color-base-200);
&:disabled {
cursor: not-allowed;
color: var(--color-base-200);
border-color: var(--color-base-150);
}
}
.icon {
width: calc(var(--base) / 2);
height: calc(var(--base) / 2);
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { Chevron } from '../Chevron'
import classes from './index.module.scss'
export const Pagination: React.FC<{
page: number
totalPages: number
onClick: (page: number) => void
className?: string
}> = (props) => {
const { page, totalPages, onClick, className } = props
const hasNextPage = page < totalPages
const hasPrevPage = page > 1
return (
<div className={[classes.pagination, className].filter(Boolean).join(' ')}>
<button
type="button"
className={classes.button}
disabled={!hasPrevPage}
onClick={() => {
onClick(page - 1)
}}
>
<Chevron rotate={90} className={classes.icon} />
</button>
<div className={classes.pageRange}>
<span className={classes.pageRangeLabel}>
Page {page} of {totalPages}
</span>
</div>
<button
type="button"
className={classes.button}
disabled={!hasNextPage}
onClick={() => {
onClick(page + 1)
}}
>
<Chevron rotate={-90} className={classes.icon} />
</button>
</div>
)
}

View File

@@ -0,0 +1,9 @@
.richText {
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
}

View File

@@ -0,0 +1,19 @@
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,102 @@
import React, { Fragment } from 'react'
import escapeHTML from 'escape-html'
import Link from 'next/link'
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.ReactNode[] =>
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 (
<Link
href={escapeHTML(node.url)}
key={i}
{...(node?.newTab
? {
target: '_blank',
rel: 'noopener noreferrer',
}
: {})}
>
{serialize(node?.children)}
</Link>
)
default:
return <p key={i}>{serialize(node?.children)}</p>
}
}) || []
export default serialize

View File

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

View File

@@ -0,0 +1,29 @@
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,117 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
:root {
--base: 24px;
--font-body: system-ui;
--font-mono: 'Roboto Mono', monospace;
--gutter-h: 180px;
--block-padding: 120px;
--theme-text: var(--color-base-750);
@include large-break {
--gutter-h: 144px;
--block-padding: 96px;
}
@include mid-break {
--gutter-h: 24px;
--block-padding: 60px;
}
}
* {
box-sizing: border-box;
}
html {
@extend %body;
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-body);
margin: 0;
color: var(--theme-text);
}
::selection {
background: var(--color-success-500);
color: var(--color-base-800);
}
::-moz-selection {
background: var(--color-success-500);
color: var(--color-base-800);
}
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) * 0.75) 0;
}
}
ul,
ol {
padding-left: var(--base);
margin: 0 0 var(--base);
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}

View File

@@ -0,0 +1,85 @@
// Keep these in sync with the colors exported in '../cssVariables.js'
:root {
--color-base-0: rgb(255, 255, 255);
--color-base-50: rgb(245, 245, 245);
--color-base-100: rgb(235, 235, 235);
--color-base-150: rgb(221, 221, 221);
--color-base-200: rgb(208, 208, 208);
--color-base-250: rgb(195, 195, 195);
--color-base-300: rgb(181, 181, 181);
--color-base-350: rgb(168, 168, 168);
--color-base-400: rgb(154, 154, 154);
--color-base-450: rgb(141, 141, 141);
--color-base-500: rgb(128, 128, 128);
--color-base-550: rgb(114, 114, 114);
--color-base-600: rgb(101, 101, 101);
--color-base-650: rgb(87, 87, 87);
--color-base-700: rgb(74, 74, 74);
--color-base-750: rgb(60, 60, 60);
--color-base-800: rgb(47, 47, 47);
--color-base-850: rgb(34, 34, 34);
--color-base-900: rgb(20, 20, 20);
--color-base-950: rgb(7, 7, 7);
--color-base-1000: rgb(0, 0, 0);
--color-success-50: rgb(247, 255, 251);
--color-success-100: rgb(240, 255, 247);
--color-success-150: rgb(232, 255, 243);
--color-success-200: rgb(224, 255, 239);
--color-success-250: rgb(217, 255, 235);
--color-success-300: rgb(209, 255, 230);
--color-success-350: rgb(201, 255, 226);
--color-success-400: rgb(193, 255, 222);
--color-success-450: rgb(186, 255, 218);
--color-success-500: rgb(178, 255, 214);
--color-success-550: rgb(160, 230, 193);
--color-success-600: rgb(142, 204, 171);
--color-success-650: rgb(125, 179, 150);
--color-success-700: rgb(107, 153, 128);
--color-success-750: rgb(89, 128, 107);
--color-success-800: rgb(71, 102, 86);
--color-success-850: rgb(53, 77, 64);
--color-success-900: rgb(36, 51, 43);
--color-success-950: rgb(18, 25, 21);
--color-warning-50: rgb(255, 255, 246);
--color-warning-100: rgb(255, 255, 237);
--color-warning-150: rgb(254, 255, 228);
--color-warning-200: rgb(254, 255, 219);
--color-warning-250: rgb(254, 255, 210);
--color-warning-300: rgb(254, 255, 200);
--color-warning-350: rgb(254, 255, 191);
--color-warning-400: rgb(253, 255, 182);
--color-warning-450: rgb(253, 255, 173);
--color-warning-500: rgb(253, 255, 164);
--color-warning-550: rgb(228, 230, 148);
--color-warning-600: rgb(202, 204, 131);
--color-warning-650: rgb(177, 179, 115);
--color-warning-700: rgb(152, 153, 98);
--color-warning-750: rgb(127, 128, 82);
--color-warning-800: rgb(101, 102, 66);
--color-warning-850: rgb(76, 77, 49);
--color-warning-900: rgb(51, 51, 33);
--color-warning-950: rgb(25, 25, 16);
--color-error-50: rgb(255, 241, 241);
--color-error-100: rgb(255, 226, 228);
--color-error-150: rgb(255, 212, 214);
--color-error-200: rgb(255, 197, 200);
--color-error-250: rgb(255, 183, 187);
--color-error-300: rgb(255, 169, 173);
--color-error-350: rgb(255, 154, 159);
--color-error-400: rgb(255, 140, 145);
--color-error-450: rgb(255, 125, 132);
--color-error-500: rgb(255, 111, 118);
--color-error-550: rgb(230, 100, 106);
--color-error-600: rgb(204, 89, 94);
--color-error-650: rgb(179, 78, 83);
--color-error-700: rgb(153, 67, 71);
--color-error-750: rgb(128, 56, 59);
--color-error-800: rgb(102, 44, 47);
--color-error-850: rgb(77, 33, 35);
--color-error-900: rgb(51, 22, 24);
--color-error-950: rgb(25, 11, 12);
}

View File

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

View File

@@ -0,0 +1,30 @@
// Keep these in sync with the breakpoints exported in '../cssVariables.js'
$breakpoint-xs-width: 400px;
$breakpoint-s-width: 768px;
$breakpoint-m-width: 1024px;
$breakpoint-l-width: 1440px;
@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,110 @@
@use 'queries' as *;
%h1,
%h2,
%h3,
%h4,
%h5,
%h6 {
font-weight: 700;
}
%h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@include mid-break {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
}
%h2 {
margin: 28px 0;
font-size: 48px;
line-height: 54px;
font-weight: bold;
@include mid-break {
margin: 22px 0;
font-size: 32px;
line-height: 40px;
}
}
%h3 {
margin: 24px 0;
font-size: 32px;
line-height: 40px;
font-weight: bold;
@include mid-break {
margin: 20px 0;
font-size: 26px;
line-height: 32px;
}
}
%h4 {
margin: 20px 0;
font-size: 26px;
line-height: 32px;
font-weight: bold;
@include mid-break {
font-size: 22px;
line-height: 30px;
}
}
%h5 {
margin: 20px 0;
font-size: 22px;
line-height: 30px;
font-weight: bold;
@include mid-break {
font-size: 18px;
line-height: 24px;
}
}
%h6 {
margin: 20px 0;
font-size: inherit;
line-height: inherit;
font-weight: bold;
}
%body {
font-size: 18px;
line-height: 32px;
@include mid-break {
font-size: 15px;
line-height: 24px;
}
}
%large-body {
font-size: 25px;
line-height: 32px;
@include mid-break {
font-size: 22px;
line-height: 30px;
}
}
%label {
font-size: 16px;
line-height: 24px;
text-transform: uppercase;
@include mid-break {
font-size: 13px;
}
}

View File

@@ -0,0 +1,55 @@
@import '../../_css/queries.scss';
.hero {
padding-top: calc(var(--base) * 2);
position: relative;
overflow: hidden;
@include large-break {
padding-top: var(--base);
}
}
.media {
width: calc(100% + var(--gutter-h));
left: calc(var(--gutter-h) / -2);
margin-top: calc(var(--base) * 3);
position: relative;
@include mid-break {
left: 0;
margin-top: var(--base);
margin-left: calc(var(--gutter-h) * -1);
width: calc(100% + var(--gutter-h) * 2);
}
}
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
padding-top: var(--base);
flex-wrap: wrap;
margin: calc(var(--base) * -0.5);
& > * {
margin: calc(var(--base) / 2);
}
}
.caption {
margin-top: var(--base);
color: var(--color-base-500);
left: calc(var(--gutter-h) / 2);
width: calc(100% - var(--gutter-h));
position: relative;
@include mid-break {
left: var(--gutter-h);
}
}
.content {
position: relative;
}

View File

@@ -0,0 +1,43 @@
import React, { Fragment } from 'react'
import { Page } from '../../../payload/payload-types'
import { Gutter } from '../../_components/Gutter'
import { CMSLink } from '../../_components/Link'
import { Media } from '../../_components/Media'
import RichText from '../../_components/RichText'
import classes from './index.module.scss'
export const HighImpactHero: React.FC<Page['hero']> = ({ richText, media, links }) => {
return (
<Gutter className={classes.hero}>
<div className={classes.content}>
<RichText content={richText} />
{Array.isArray(links) && links.length > 0 && (
<ul className={classes.links}>
{links.map(({ link }, i) => {
return (
<li key={i}>
<CMSLink {...link} />
</li>
)
})}
</ul>
)}
</div>
<div className={classes.media}>
{typeof media === 'object' && (
<Fragment>
<Media
resource={media}
// fill
imgClassName={classes.image}
priority
/>
{media?.caption && <RichText content={media.caption} className={classes.caption} />}
</Fragment>
)}
</div>
</Gutter>
)
}

View File

@@ -0,0 +1,4 @@
@use '../../_css/type.scss' as *;
.lowImpactHero {
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { Page } from '../../../payload/payload-types'
import { Gutter } from '../../_components/Gutter'
import RichText from '../../_components/RichText'
import { VerticalPadding } from '../../_components/VerticalPadding'
import classes from './index.module.scss'
export const LowImpactHero: React.FC<Page['hero']> = ({ richText }) => {
return (
<Gutter className={classes.lowImpactHero}>
<div className={classes.content}>
<VerticalPadding>
<RichText className={classes.richText} content={richText} />
</VerticalPadding>
</div>
</Gutter>
)
}

View File

@@ -0,0 +1,80 @@
@use '../../_css/common.scss' as *;
.postHero {
display: flex;
gap: calc(var(--base) * 2);
@include mid-break {
flex-direction: column;
gap: var(--base);
}
}
.content {
width: 50%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--base);
@include mid-break {
width: 100%;
gap: calc(var(--base) / 2);
}
}
.title {
margin: 0;
}
.warning {
margin-bottom: calc(var(--base) * 1.5);
}
.meta {
margin: 0;
}
.description {
margin: 0;
}
.media {
width: 50%;
@include mid-break {
width: 100%;
}
}
.mediaWrapper {
text-decoration: none;
display: block;
position: relative;
aspect-ratio: 5 / 4;
margin-bottom: calc(var(--base) / 2);
width: calc(100% + calc(var(--gutter-h) / 2));
@include mid-break {
margin-left: calc(var(--gutter-h) * -1);
width: calc(100% + var(--gutter-h) * 2);
}
}
.image {
object-fit: cover;
}
.placeholder {
background-color: var(--color-base-50);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.caption {
color: var(--color-base-500);
}

View File

@@ -0,0 +1,55 @@
import React, { Fragment } from 'react'
import Link from 'next/link'
import { Post } from '../../../payload-types'
import { Gutter } from '../../_components/Gutter'
import { Media } from '../../_components/Media'
import RichText from '../../_components/RichText'
import { formatDateTime } from '../../_utilities/formatDateTime'
import classes from './index.module.scss'
import { PAYLOAD_SERVER_URL } from '@/app/_api/serverURL'
export const PostHero: React.FC<{
post: Post
}> = ({ post }) => {
const { id, title, meta: { image: metaImage, description } = {}, createdAt } = post
return (
<Fragment>
<Gutter className={classes.postHero}>
<div className={classes.content}>
<h1 className={classes.title}>{title}</h1>
<p className={classes.meta}>
{createdAt && (
<Fragment>
{'Created on '}
{formatDateTime(createdAt)}
</Fragment>
)}
</p>
<div>
<p className={classes.description}>
{`${description ? `${description} ` : ''}To edit this post, `}
<Link href={`${PAYLOAD_SERVER_URL}/admin/collections/posts/${id}`}>
navigate to the admin dashboard
</Link>
{'.'}
</p>
</div>
</div>
<div className={classes.media}>
<div className={classes.mediaWrapper}>
{!metaImage && <div className={classes.placeholder}>No image</div>}
{metaImage && typeof metaImage !== 'string' && (
<Media imgClassName={classes.image} resource={metaImage} fill />
)}
</div>
{metaImage && typeof metaImage !== 'string' && metaImage?.caption && (
<RichText content={metaImage.caption} className={classes.caption} />
)}
</div>
</Gutter>
</Fragment>
)
}

View File

@@ -0,0 +1,20 @@
export const formatDateTime = (timestamp: string): string => {
const now = new Date()
let date = now
if (timestamp) date = new Date(timestamp)
const months = date.getMonth()
const days = date.getDate()
// const hours = date.getHours();
// const minutes = date.getMinutes();
// const seconds = date.getSeconds();
const MM = months + 1 < 10 ? `0${months + 1}` : months + 1
const DD = days < 10 ? `0${days}` : days
const YYYY = date.getFullYear()
// const AMPM = hours < 12 ? 'AM' : 'PM';
// const HH = hours > 12 ? hours - 12 : hours;
// const MinMin = (minutes < 10) ? `0${minutes}` : minutes;
// const SS = (seconds < 10) ? `0${seconds}` : seconds;
return `${MM}/${DD}/${YYYY}`
}

View File

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

View File

@@ -1,19 +0,0 @@
import { Page } from '@/payload-types'
export const PAYLOAD_SERVER_URL = 'http://localhost:3000'
export const getPage = async (slug: string): Promise<Page> => {
return await fetch(`http://localhost:3000/api/pages?where[slug][equals]=${slug}`, {
method: 'GET',
cache: 'no-store',
})
.then((res) => {
if (!res.ok) {
console.error(`Error fetching page: ${res.status} ${res.statusText}`)
return null
}
return res?.json()
})
?.then((res) => res?.docs?.[0])
}

View File

@@ -0,0 +1,17 @@
// Keep these in sync with the CSS variables in the `_css` directory
module.exports = {
breakpoints: {
s: 768,
m: 1024,
l: 1440,
},
colors: {
base0: 'rgb(255, 255, 255)',
base100: 'rgb(235, 235, 235)',
base500: 'rgb(128, 128, 128)',
base850: 'rgb(34, 34, 34)',
base1000: 'rgb(0, 0, 0)',
error500: 'rgb(255, 111, 118)',
},
}

View File

@@ -1,49 +0,0 @@
:root {
--max-width: 1100px;
--foreground-rgb: 0, 0, 0;
--background-rbg: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-rbg: 0, 0, 0;
}
}
* {
box-sizing: border-box;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
margin: 0;
padding: 0;
}
body {
color: rgb(var(--foreground-rgb));
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Ubuntu,
'Helvetica Neue',
sans-serif;
background: rgb(var(--background-rbg));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -1,4 +1,6 @@
import './globals.css'
import { Footer } from './_components/Footer'
import { Header } from './_components/Header'
import './_css/app.scss'
import type { Metadata } from 'next'
export const metadata: Metadata = {
@@ -9,7 +11,11 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}

View File

@@ -0,0 +1,18 @@
'use client'
import React from 'react'
import { Gutter } from './_components/Gutter'
import { VerticalPadding } from './_components/VerticalPadding'
export default function NotFound() {
return (
<main>
<VerticalPadding top="none" bottom="medium">
<Gutter>
<h1>404</h1>
<p>This page could not be found.</p>
</Gutter>
</VerticalPadding>
</main>
)
}

View File

@@ -1,65 +0,0 @@
'use client'
import React, { Fragment } from 'react'
import styles from './page.module.css'
import { PAYLOAD_SERVER_URL } from './api'
// The `useLivePreview` hook is imported from the monorepo for development purposes only
// in your own app you would import this hook directly from the package itself
// i.e. `import { useLivePreview } from '@payloadcms/live-preview-react'`
// If you are using another framework, look for the equivalent packages for your framework
import { useLivePreview } from '../../../../packages/live-preview-react'
import { Page as PageType } from '@/payload-types'
export type Props = {
initialPage: PageType
}
export const Page: React.FC<Props> = (props) => {
const { initialPage } = props
const { data, isLoading } = useLivePreview<PageType>({
initialData: initialPage,
serverURL: PAYLOAD_SERVER_URL,
})
return (
<main className={styles.main}>
{isLoading && <Fragment>Loading...</Fragment>}
{!isLoading && (
<Fragment>
<h1>{data.title}</h1>
<p>{data.description}</p>
{data.layout && (
<div>
<p>Blocks</p>
<div className={styles.blocks}>
{data.layout.map((block, index) => {
const { title, description } = block
return (
<div key={index}>
<h2>{title}</h2>
<p>{description}</p>
</div>
)
})}
</div>
</div>
)}
<br />
<hr />
<br />
{data.featuredPosts && (
<div>
<p>Featured Posts</p>
<ul className={styles['featured-posts']}>
{data.featuredPosts.map((post, index) => (
<li key={index}>{typeof post === 'string' ? post : post.id}</li>
))}
</ul>
</div>
)}
</Fragment>
)}
</main>
)
}

View File

@@ -1,18 +0,0 @@
.main {
padding: 6rem;
min-height: 100vh;
}
.main > *:first-child {
margin-top: 0;
}
.main > *:last-child {
margin-bottom: 0;
}
.featured-posts {
margin: 0;
padding: 0;
list-style: none;
}

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