chore: builds live preview app (#3451)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
9
pnpm-lock.yaml
generated
@@ -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
2
test/live-preview/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
99
test/live-preview/blocks/ArchiveBlock/index.ts
Normal file
99
test/live-preview/blocks/ArchiveBlock/index.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
26
test/live-preview/blocks/CallToAction/index.ts
Normal file
26
test/live-preview/blocks/CallToAction/index.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
58
test/live-preview/blocks/Content/index.ts
Normal file
58
test/live-preview/blocks/Content/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
31
test/live-preview/blocks/MediaBlock/index.ts
Normal file
31
test/live-preview/blocks/MediaBlock/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
19
test/live-preview/collections/Categories.ts
Normal file
19
test/live-preview/collections/Categories.ts
Normal 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
|
||||
20
test/live-preview/collections/Media.ts
Normal file
20
test/live-preview/collections/Media.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
94
test/live-preview/collections/Pages.ts
Normal file
94
test/live-preview/collections/Pages.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
107
test/live-preview/collections/Posts.ts
Normal file
107
test/live-preview/collections/Posts.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
44
test/live-preview/fields/hero.ts
Normal file
44
test/live-preview/fields/hero.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
6
test/live-preview/fields/invertBackground.ts
Normal file
6
test/live-preview/fields/invertBackground.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CheckboxField } from '../../../packages/payload/src/fields/config/types'
|
||||
|
||||
export const invertBackground: CheckboxField = {
|
||||
name: 'invertBackground',
|
||||
type: 'checkbox',
|
||||
}
|
||||
150
test/live-preview/fields/link.ts
Normal file
150
test/live-preview/fields/link.ts
Normal 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
|
||||
26
test/live-preview/fields/linkGroup.ts
Normal file
26
test/live-preview/fields/linkGroup.ts
Normal 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
|
||||
18
test/live-preview/globals/Footer.ts
Normal file
18
test/live-preview/globals/Footer.ts
Normal 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()],
|
||||
},
|
||||
],
|
||||
}
|
||||
18
test/live-preview/globals/Header.ts
Normal file
18
test/live-preview/globals/Header.ts
Normal 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()],
|
||||
},
|
||||
],
|
||||
}
|
||||
BIN
test/live-preview/image-1.jpg
Normal file
BIN
test/live-preview/image-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
54
test/live-preview/int.spec.ts
Normal file
54
test/live-preview/int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
35
test/live-preview/next-app/app/(pages)/[slug]/page.tsx
Normal file
35
test/live-preview/next-app/app/(pages)/[slug]/page.tsx
Normal 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 []
|
||||
}
|
||||
}
|
||||
92
test/live-preview/next-app/app/(pages)/posts/[slug]/page.tsx
Normal file
92
test/live-preview/next-app/app/(pages)/posts/[slug]/page.tsx
Normal 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 []
|
||||
}
|
||||
}
|
||||
30
test/live-preview/next-app/app/_api/fetchDoc.ts
Normal file
30
test/live-preview/next-app/app/_api/fetchDoc.ts
Normal 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
|
||||
}
|
||||
20
test/live-preview/next-app/app/_api/fetchDocs.ts
Normal file
20
test/live-preview/next-app/app/_api/fetchDocs.ts
Normal 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
|
||||
}
|
||||
24
test/live-preview/next-app/app/_api/fetchFooter.ts
Normal file
24
test/live-preview/next-app/app/_api/fetchFooter.ts
Normal 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
|
||||
}
|
||||
24
test/live-preview/next-app/app/_api/fetchHeader.ts
Normal file
24
test/live-preview/next-app/app/_api/fetchHeader.ts
Normal 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
|
||||
}
|
||||
1
test/live-preview/next-app/app/_api/serverURL.ts
Normal file
1
test/live-preview/next-app/app/_api/serverURL.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PAYLOAD_SERVER_URL = 'http://localhost:3000'
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Page } from '../../../payload-types'
|
||||
|
||||
export type ArchiveBlockProps = Extract<Page['layout'][0], { blockType: 'archive' }>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
37
test/live-preview/next-app/app/_blocks/Content/index.tsx
Normal file
37
test/live-preview/next-app/app/_blocks/Content/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.mediaBlock {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.caption {
|
||||
color: var(--color-base-500);
|
||||
margin-top: var(--base);
|
||||
}
|
||||
41
test/live-preview/next-app/app/_blocks/MediaBlock/index.tsx
Normal file
41
test/live-preview/next-app/app/_blocks/MediaBlock/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.invert {
|
||||
background-color: var(--color-base-750);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
81
test/live-preview/next-app/app/_components/Blocks/index.tsx
Normal file
81
test/live-preview/next-app/app/_components/Blocks/index.tsx
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
test/live-preview/next-app/app/_components/Button/index.tsx
Normal file
76
test/live-preview/next-app/app/_components/Button/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
86
test/live-preview/next-app/app/_components/Card/index.tsx
Normal file
86
test/live-preview/next-app/app/_components/Card/index.tsx
Normal 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>, </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>
|
||||
)
|
||||
}
|
||||
26
test/live-preview/next-app/app/_components/Chevron/index.tsx
Normal file
26
test/live-preview/next-app/app/_components/Chevron/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
40
test/live-preview/next-app/app/_components/Footer/index.tsx
Normal file
40
test/live-preview/next-app/app/_components/Footer/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
33
test/live-preview/next-app/app/_components/Gutter/index.tsx
Normal file
33
test/live-preview/next-app/app/_components/Gutter/index.tsx
Normal 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'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
31
test/live-preview/next-app/app/_components/Header/index.tsx
Normal file
31
test/live-preview/next-app/app/_components/Header/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
test/live-preview/next-app/app/_components/Hero/index.tsx
Normal file
22
test/live-preview/next-app/app/_components/Hero/index.tsx
Normal 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} />
|
||||
}
|
||||
63
test/live-preview/next-app/app/_components/Link/index.tsx
Normal file
63
test/live-preview/next-app/app/_components/Link/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.placeholder-color-light {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background-color: var(--color-base-50);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.video {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-base-50);
|
||||
}
|
||||
|
||||
.cover {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
28
test/live-preview/next-app/app/_components/Media/index.tsx
Normal file
28
test/live-preview/next-app/app/_components/Media/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
test/live-preview/next-app/app/_components/Media/types.ts
Normal file
20
test/live-preview/next-app/app/_components/Media/types.ts
Normal 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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.richText {
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
117
test/live-preview/next-app/app/_css/app.scss
Normal file
117
test/live-preview/next-app/app/_css/app.scss
Normal 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;
|
||||
}
|
||||
85
test/live-preview/next-app/app/_css/colors.scss
Normal file
85
test/live-preview/next-app/app/_css/colors.scss
Normal 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);
|
||||
}
|
||||
2
test/live-preview/next-app/app/_css/common.scss
Normal file
2
test/live-preview/next-app/app/_css/common.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@forward './queries.scss';
|
||||
@forward './type.scss';
|
||||
30
test/live-preview/next-app/app/_css/queries.scss
Normal file
30
test/live-preview/next-app/app/_css/queries.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
110
test/live-preview/next-app/app/_css/type.scss
Normal file
110
test/live-preview/next-app/app/_css/type.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
43
test/live-preview/next-app/app/_heros/HighImpact/index.tsx
Normal file
43
test/live-preview/next-app/app/_heros/HighImpact/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@use '../../_css/type.scss' as *;
|
||||
|
||||
.lowImpactHero {
|
||||
}
|
||||
20
test/live-preview/next-app/app/_heros/LowImpact/index.tsx
Normal file
20
test/live-preview/next-app/app/_heros/LowImpact/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
55
test/live-preview/next-app/app/_heros/PostHero/index.tsx
Normal file
55
test/live-preview/next-app/app/_heros/PostHero/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
test/live-preview/next-app/app/_utilities/formatDateTime.ts
Normal file
20
test/live-preview/next-app/app/_utilities/formatDateTime.ts
Normal 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}`
|
||||
}
|
||||
5
test/live-preview/next-app/app/_utilities/toKebabCase.ts
Normal file
5
test/live-preview/next-app/app/_utilities/toKebabCase.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const toKebabCase = (string: string): string =>
|
||||
string
|
||||
?.replace(/([a-z])([A-Z])/g, '$1-$2')
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase()
|
||||
@@ -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])
|
||||
}
|
||||
17
test/live-preview/next-app/app/cssVariables.js
Normal file
17
test/live-preview/next-app/app/cssVariables.js
Normal 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)',
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
18
test/live-preview/next-app/app/not-found.tsx
Normal file
18
test/live-preview/next-app/app/not-found.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user