Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/blocks/BlogList/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
} from '@payloadcms/richtext-lexical'
import type { Block, Field, FilterOptionsProps } from 'payload'
import { ButtonBlock } from '../Button/config'
import { FormEmbedBlock } from '../FormEmbed/config'
import { GenericEmbedBlock } from '../GenericEmbed/config'
import { MediaBlock } from '../Media/config'
import { VideoEmbedBlock } from '../VideoEmbed/config'
import { validateMaxPosts } from './hooks/validateMaxPosts'

const defaultStylingFields: Field[] = [
Expand All @@ -23,7 +25,7 @@ const defaultStylingFields: Field[] = [
return [
...rootFeatures,
BlocksFeature({
blocks: [ButtonBlock, MediaBlock, GenericEmbedBlock],
blocks: [ButtonBlock, MediaBlock, GenericEmbedBlock, FormEmbedBlock, VideoEmbedBlock],
inlineBlocks: DEFAULT_INLINE_BLOCKS,
}),
HorizontalRuleFeature(),
Expand Down
4 changes: 4 additions & 0 deletions src/blocks/Callout/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import colorPickerField from '@/fields/color'
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { BlogListBlock } from '../BlogList/config'
import { ButtonBlock } from '../Button/config'
import { FormEmbedBlock } from '../FormEmbed/config'
import { GenericEmbedBlock } from '../GenericEmbed/config'
import { MediaBlock } from '../Media/config'
import { SingleBlogPostBlock } from '../SingleBlogPost/config'
import { VideoEmbedBlock } from '../VideoEmbed/config'

export const CalloutBlock: Block = {
slug: 'calloutBlock',
Expand All @@ -28,6 +30,8 @@ export const CalloutBlock: Block = {
BlogListBlock,
ButtonBlock,
GenericEmbedBlock,
FormEmbedBlock,
VideoEmbedBlock,
MediaBlock,
SingleBlogPostBlock,
],
Expand Down
4 changes: 4 additions & 0 deletions src/blocks/Content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { CalloutBlock } from '../Callout/config'
import { DocumentBlock } from '../Document/config'
import { EventListBlock } from '../EventList/config'
import { EventTableBlock } from '../EventTable/config'
import { FormEmbedBlock } from '../FormEmbed/config'
import { GenericEmbedBlock } from '../GenericEmbed/config'
import { HeaderLexicalBlock } from '../Header/config'
import { MediaBlock } from '../Media/config'
import { SingleBlogPostBlock } from '../SingleBlogPost/config'
import { SingleEventBlock } from '../SingleEvent/config'
import { SponsorsBlock } from '../Sponsors/config'
import { VideoEmbedBlock } from '../VideoEmbed/config'
import { healColumnLayout } from './hooks/healColumnLayout'

const validateColumnLayout: SelectFieldValidation = (value, args) => {
Expand Down Expand Up @@ -142,6 +144,8 @@ export const ContentBlock: Block = {
EventTableBlock,
SingleEventBlock,
GenericEmbedBlock,
FormEmbedBlock,
VideoEmbedBlock,
HeaderLexicalBlock,
MediaBlock,
SingleBlogPostBlock,
Expand Down
19 changes: 19 additions & 0 deletions src/blocks/FormEmbed/Component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { EmbedFrame } from '@/components/EmbedFrame'
import { BASE_ADD_ATTR } from '@/components/EmbedFrame/policies'
import type { FormEmbedBlock as FormEmbedBlockProps } from 'src/payload-types'

type Props = FormEmbedBlockProps & {
isLayoutBlock: boolean
className?: string
}

const FORM_EMBED_POLICY = {
addTags: ['iframe', 'script', 'style', 'dbox-widget'],
addAttr: [...BASE_ADD_ATTR, 'allowpaymentrequest', 'campaign', 'enable-auto-scroll'],
sandbox:
'allow-scripts allow-presentation allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
}

export const FormEmbedBlockComponent = (props: Props) => (
<EmbedFrame {...props} {...FORM_EMBED_POLICY} />
)
29 changes: 29 additions & 0 deletions src/blocks/FormEmbed/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import alignContentField from '@/fields/alignContent'
import colorPickerField from '@/fields/color'
import type { Block } from 'payload'

export const FormEmbedBlock: Block = {
slug: 'formEmbed',
imageURL: '/thumbnail/FormThumbnail.jpg',
interfaceName: 'FormEmbedBlock',
labels: {
singular: 'Form Embed',
plural: 'Form Embeds',
},
fields: [
{
name: 'html',
label: 'Form embed code',
type: 'textarea',
required: true,
admin: {
description:
'For donation and form widgets that ship their own scripts (DonorBox, Classy, Eventbrite, etc.). Paste the provider embed code, including any <script> tags. Helpful tip: <iframe> tags should have hardcoded height and width. You can use relative (100%) or pixel values (600px) for width. You must use pixel values for height.',
},
},
{
type: 'row',
fields: [colorPickerField('Background color'), alignContentField('Content alignment')],
},
],
}
118 changes: 11 additions & 107 deletions src/blocks/GenericEmbed/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,115 +1,19 @@
'use client'

import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor'
import { cn } from '@/utilities/ui'
import { IframeResizer } from '@open-iframe-resizer/react'
import DOMPurify from 'dompurify'
import { useEffect, useState } from 'react'
import { EmbedFrame } from '@/components/EmbedFrame'
import { BASE_ADD_ATTR } from '@/components/EmbedFrame/policies'
import type { GenericEmbedBlock as GenericEmbedBlockProps } from 'src/payload-types'

type Props = GenericEmbedBlockProps & {
isLayoutBlock: boolean
className?: string
}

type IframeContent = { type: 'srcDoc'; value: string } | { type: 'src'; value: string }

export const GenericEmbedBlockComponent = ({
id,
html,
backgroundColor,
alignContent = 'left',
className,
isLayoutBlock = true,
}: Props) => {
const [iframeContent, setIframeContent] = useState<IframeContent | null>(null)

const bgColorClass = `bg-${backgroundColor}`
const textColor = getTextColorFromBgColor(backgroundColor)

useEffect(() => {
if (typeof window === 'undefined' || !html) return

// Normalize problematic quotes that are parsed incorrectly by DOMParser and DOMPurify
const normalizedHTML = html.replaceAll('\u201C', '"').replaceAll('\u201D', '"')

const sanitized = DOMPurify.sanitize(normalizedHTML, {
ADD_TAGS: ['iframe', 'script', 'style', 'dbox-widget'],
ADD_ATTR: [
'allow',
'allowfullscreen',
'allowpaymentrequest',
'async',
'campaign',
'enable-auto-scroll',
'frameborder',
'height',
'id',
'name',
'sandbox',
'scrolling',
'src',
'style',
'title',
'type',
'width',
],
FORCE_BODY: true,
})

const styleOverrides = `
<style>
html, body {
margin: 0;
padding: 0;
}
iframe {
border: 0
}
</style>
`

const fullHtml = `<!DOCTYPE html><html><head></head><body>${sanitized}${styleOverrides}</body></html>`

// Use a blob URL for embeds with <script> tags because Chromium doesn't re-execute
// scripts in srcDoc iframes after SPA client-side navigation (renderer-process MemoryCache).
// Use srcDoc for script-free embeds (e.g. YouTube iframes) so the embedding context is
// preserved — blob: URLs can cause third-party players to fail their origin/referrer checks.
if (/<script/i.test(sanitized)) {
const blob = new Blob([fullHtml], { type: 'text/html' })
const url = URL.createObjectURL(blob)
setIframeContent({ type: 'src', value: url })
return () => URL.revokeObjectURL(url)
} else {
setIframeContent({ type: 'srcDoc', value: fullHtml })
}
}, [html])

if (iframeContent === null) return null

return (
<div className={cn(bgColorClass, textColor)}>
<div
className={cn(
isLayoutBlock && 'container py-10',
'flex flex-col',
alignContent === 'left' && 'items-start',
alignContent === 'center' && 'items-center',
alignContent === 'right' && 'items-end',
className,
)}
>
<IframeResizer
id={String(id)}
title={`Embedded content ${id}`}
{...(iframeContent.type === 'src'
? { src: iframeContent.value }
: { srcDoc: iframeContent.value })}
sandbox="allow-scripts allow-presentation allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
className="w-full border-none m-0 p-0 transition-[height] duration-200 ease-in-out"
height={0} // This iframe will resize to its content height - this initial height avoids the browser default 150px
/>
</div>
</div>
)
const GENERIC_EMBED_POLICY = {
addTags: ['iframe', 'script', 'style'],
addAttr: BASE_ADD_ATTR,
sandbox:
'allow-scripts allow-presentation allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
}

export const GenericEmbedBlockComponent = (props: Props) => (
<EmbedFrame {...props} {...GENERIC_EMBED_POLICY} />
)
6 changes: 5 additions & 1 deletion src/blocks/GenericEmbed/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const GenericEmbedBlock: Block = {
slug: 'genericEmbed',
imageURL: '/thumbnail/GenericEmbedThumbnail.jpg',
interfaceName: 'GenericEmbedBlock',
labels: {
singular: 'Generic Embed',
plural: 'Generic Embeds',
},
fields: [
{
name: 'html',
Expand All @@ -14,7 +18,7 @@ export const GenericEmbedBlock: Block = {
required: true,
admin: {
description:
'Helpful tip: <iframe> tags should have hardcoded height and width. You can use relative (100%) or pixel values (600px) for width. You must use pixel values for height.',
'For arbitrary HTML/iframe embeds. For videos use the Video Embed block, and for donation or form widgets (DonorBox, etc.) use the Form Embed block. Helpful tip: <iframe> tags should have hardcoded height and width. You can use relative (100%) or pixel values (600px) for width. You must use pixel values for height.',
},
},
{
Expand Down
6 changes: 6 additions & 0 deletions src/blocks/RenderBlocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DocumentBlockComponent } from '@/blocks/Document/Component'
import { EventListBlockComponent } from '@/blocks/EventList/Component'
import { EventTableBlockComponent } from '@/blocks/EventTable/Component'
import { FormBlockComponent } from '@/blocks/Form/Component'
import { FormEmbedBlockComponent } from '@/blocks/FormEmbed/Component'
import { GenericEmbedBlockComponent } from '@/blocks/GenericEmbed/Component'
import { HeaderBlockComponent } from '@/blocks/Header/Component'
import { ImageLinkGridBlockComponent } from '@/blocks/ImageLinkGrid/Component'
Expand All @@ -20,6 +21,7 @@ import { SingleBlogPostBlockComponent } from '@/blocks/SingleBlogPost/Component'
import { SingleEventBlockComponent } from '@/blocks/SingleEvent/Component'
import { SponsorsBlockComponent } from '@/blocks/Sponsors/components'
import { TeamBlockComponent } from '@/blocks/Team/Component'
import { VideoEmbedBlockComponent } from '@/blocks/VideoEmbed/Component'

export const RenderBlocks = (props: { blocks: Page['layout'][0][]; payload: Payload }) => {
const { blocks } = props
Expand Down Expand Up @@ -61,6 +63,10 @@ export const RenderBlock = ({ block }: { block: Page['layout'][0] }) => {
return <FormBlockComponent {...block} />
case 'genericEmbed':
return <GenericEmbedBlockComponent {...block} isLayoutBlock={true} />
case 'formEmbed':
return <FormEmbedBlockComponent {...block} isLayoutBlock={true} />
case 'videoEmbed':
return <VideoEmbedBlockComponent {...block} isLayoutBlock={true} />
case 'headerBlock':
return <HeaderBlockComponent {...block} isLayoutBlock={true} />
case 'imageLinkGrid':
Expand Down
18 changes: 18 additions & 0 deletions src/blocks/VideoEmbed/Component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { EmbedFrame } from '@/components/EmbedFrame'
import { BASE_ADD_ATTR } from '@/components/EmbedFrame/policies'
import type { VideoEmbedBlock as VideoEmbedBlockProps } from 'src/payload-types'

type Props = VideoEmbedBlockProps & {
isLayoutBlock: boolean
className?: string
}
const VIDEO_EMBED_POLICY = {
addTags: ['iframe', 'style'],
addAttr: BASE_ADD_ATTR,
sandbox:
'allow-scripts allow-presentation allow-same-origin allow-popups allow-popups-to-escape-sandbox',
}

export const VideoEmbedBlockComponent = (props: Props) => (
<EmbedFrame {...props} {...VIDEO_EMBED_POLICY} />
)
29 changes: 29 additions & 0 deletions src/blocks/VideoEmbed/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import alignContentField from '@/fields/alignContent'
import colorPickerField from '@/fields/color'
import type { Block } from 'payload'

export const VideoEmbedBlock: Block = {
slug: 'videoEmbed',
imageURL: '/thumbnail/GenericEmbedThumbnail.jpg',
interfaceName: 'VideoEmbedBlock',
labels: {
singular: 'Video Embed',
plural: 'Video Embeds',
},
fields: [
{
name: 'html',
label: 'Video embed code',
type: 'textarea',
required: true,
admin: {
description:
'Paste the embed code (<iframe>) from a video provider such as YouTube or Vimeo. Scripts are not executed in this block. Helpful tip: <iframe> tags should have hardcoded height and width. You can use relative (100%) or pixel values (600px) for width. You must use pixel values for height.',
},
},
{
type: 'row',
fields: [colorPickerField('Background color'), alignContentField('Content alignment')],
},
],
}
4 changes: 4 additions & 0 deletions src/collections/Events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { BlogListBlock } from '@/blocks/BlogList/config'
import { DocumentBlock } from '@/blocks/Document/config'
import { EventListBlock } from '@/blocks/EventList/config'
import { EventTableBlock } from '@/blocks/EventTable/config'
import { FormEmbedBlock } from '@/blocks/FormEmbed/config'
import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config'
import { HeaderLexicalBlock } from '@/blocks/Header/config'
import { MediaBlock } from '@/blocks/Media/config'
import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config'
import { SingleEventBlock } from '@/blocks/SingleEvent/config'
import { SponsorsBlock } from '@/blocks/Sponsors/config'
import { VideoEmbedBlock } from '@/blocks/VideoEmbed/config'
import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks'
import { eventTypesData } from '@/constants/eventTypes'
import { contentHashField } from '@/fields/contentHashField'
Expand Down Expand Up @@ -161,6 +163,8 @@ export const Events: CollectionConfig = {
EventListBlock,
EventTableBlock,
GenericEmbedBlock,
FormEmbedBlock,
VideoEmbedBlock,
HeaderLexicalBlock,
MediaBlock,
SingleBlogPostBlock,
Expand Down
4 changes: 4 additions & 0 deletions src/collections/HomePages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { tenantField } from '@/fields/tenantField'
import type { CollectionConfig } from 'payload'

import { ButtonBlock } from '@/blocks/Button/config'
import { FormEmbedBlock } from '@/blocks/FormEmbed/config'
import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config'
import { MediaBlock } from '@/blocks/Media/config'
import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config'
import { SponsorsBlock } from '@/blocks/Sponsors/config'
import { VideoEmbedBlock } from '@/blocks/VideoEmbed/config'
import { DEFAULT_INLINE_BLOCKS } from '@/constants/defaultInlineBlocks'

import { DocumentBlock } from '@/blocks/Document/config'
Expand Down Expand Up @@ -108,6 +110,8 @@ export const HomePages: CollectionConfig = {
ButtonBlock,
DocumentBlock,
GenericEmbedBlock,
FormEmbedBlock,
VideoEmbedBlock,
HeaderLexicalBlock,
MediaBlock,
SingleBlogPostBlock,
Expand Down
Loading
Loading