From 58352128fa3cbf6e592ebff214a989e50af052b9 Mon Sep 17 00:00:00 2001 From: Kellen Busby Date: Tue, 3 Mar 2026 11:54:17 -0800 Subject: [PATCH 1/4] Add InlineMedia inline block with position, size, and alignment options Introduces a new inline block for embedding images within rich text content. Supports float left/right with text wrapping and inline positioning with configurable vertical alignment. Registered in defaultLexical and Posts collection. Co-Authored-By: Claude Opus 4.6 --- src/blocks/InlineMedia/Component.tsx | 58 +++++++++++++++++++++++++ src/blocks/InlineMedia/config.ts | 65 ++++++++++++++++++++++++++++ src/collections/Posts/index.ts | 2 + src/components/RichText/index.tsx | 7 +++ src/fields/defaultLexical.ts | 2 + src/payload-types.ts | 26 +++++++++++ 6 files changed, 160 insertions(+) create mode 100644 src/blocks/InlineMedia/Component.tsx create mode 100644 src/blocks/InlineMedia/config.ts diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx new file mode 100644 index 000000000..3fe15d9db --- /dev/null +++ b/src/blocks/InlineMedia/Component.tsx @@ -0,0 +1,58 @@ +import type { InlineMediaBlock } from '@/payload-types' + +import { Media } from '@/components/Media' +import { cn } from '@/utilities/ui' + +type Props = Omit + +const inlineSizeClasses = { + small: 'max-h-6', + medium: 'max-h-10', + large: 'max-h-16', + full: 'max-h-24', +} as const + +const floatSizeClasses = { + small: 'max-w-[8rem]', + medium: 'max-w-[12rem]', + large: 'max-w-[16rem]', + full: 'max-w-[24rem]', +} as const + +const verticalAlignClasses = { + top: 'align-top', + middle: 'align-middle', + bottom: 'align-bottom', + baseline: 'align-baseline', +} as const + +export const InlineMediaComponent = ({ + media, + position = 'inline', + verticalAlign = 'middle', + size = 'small', + caption, +}: Props) => { + if (!media || typeof media === 'number' || typeof media === 'string') { + return null + } + + const isFloat = position === 'float-left' || position === 'float-right' + + const sizeClass = isFloat ? floatSizeClasses[size ?? 'small'] : inlineSizeClasses[size ?? 'small'] + + const positionClasses = isFloat + ? cn(position === 'float-left' ? 'float-left mr-2' : 'float-right ml-2', 'mb-1') + : cn('inline-block', verticalAlignClasses[verticalAlign ?? 'middle']) + + return ( + + + + ) +} diff --git a/src/blocks/InlineMedia/config.ts b/src/blocks/InlineMedia/config.ts new file mode 100644 index 000000000..722e03260 --- /dev/null +++ b/src/blocks/InlineMedia/config.ts @@ -0,0 +1,65 @@ +import type { Block } from 'payload' + +export const InlineMediaBlock: Block = { + slug: 'inlineMedia', + interfaceName: 'InlineMediaBlock', + fields: [ + { + name: 'media', + type: 'upload', + relationTo: 'media', + required: true, + }, + { + name: 'position', + type: 'select', + defaultValue: 'inline', + options: [ + { label: 'Inline', value: 'inline' }, + { label: 'Float left', value: 'float-left' }, + { label: 'Float right', value: 'float-right' }, + ], + admin: { + description: + 'Inline renders the image within the text flow. Float positions the image to one side with text wrapping around it.', + }, + }, + { + name: 'verticalAlign', + type: 'select', + defaultValue: 'middle', + options: [ + { label: 'Middle', value: 'middle' }, + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'Baseline', value: 'baseline' }, + ], + admin: { + description: 'Vertical alignment relative to the surrounding text.', + condition: (_, siblingData) => siblingData?.position === 'inline', + }, + }, + { + name: 'size', + type: 'select', + defaultValue: 'small', + options: [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large', value: 'large' }, + { label: 'Full', value: 'full' }, + ], + admin: { + description: + 'Controls the maximum size of the image. When inline, this sets max height. When floating, this sets max width.', + }, + }, + { + name: 'caption', + type: 'text', + admin: { + description: 'Optional text shown as a tooltip on hover.', + }, + }, + ], +} diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index ab03c22ce..682cf95fc 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -15,6 +15,7 @@ import { EventListBlock } from '@/blocks/EventList/config' import { EventTableBlock } from '@/blocks/EventTable/config' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config' import { HeaderLexicalBlock } from '@/blocks/Header/config' +import { InlineMediaBlock } from '@/blocks/InlineMedia/config' import { MediaBlock } from '@/blocks/Media/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { SingleEventBlock } from '@/blocks/SingleEvent/config' @@ -102,6 +103,7 @@ export const Posts: CollectionConfig<'posts'> = { SingleEventBlock, SponsorsBlock, ], + inlineBlocks: [InlineMediaBlock], }), HorizontalRuleFeature(), InlineToolbarFeature(), diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx index 4167b351d..23aaa3f70 100644 --- a/src/components/RichText/index.tsx +++ b/src/components/RichText/index.tsx @@ -2,6 +2,7 @@ import { MediaBlockComponent } from '@/blocks/Media/Component' import { DefaultNodeTypes, SerializedBlockNode, + SerializedInlineBlockNode, SerializedLinkNode, } from '@payloadcms/richtext-lexical' import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' @@ -19,6 +20,7 @@ import { EventListBlockComponent } from '@/blocks/EventList/Component' import { EventTableBlockComponent } from '@/blocks/EventTable/Component' import { GenericEmbedBlockComponent } from '@/blocks/GenericEmbed/Component' import { HeaderBlockComponent } from '@/blocks/Header/Component' +import { InlineMediaComponent } from '@/blocks/InlineMedia/Component' import { SingleBlogPostBlockComponent } from '@/blocks/SingleBlogPost/Component' import { SingleEventBlockComponent } from '@/blocks/SingleEvent/Component' import { SponsorsBlockComponent } from '@/blocks/Sponsors/components' @@ -33,6 +35,7 @@ import type { EventTableBlock as EventTableBlockProps, GenericEmbedBlock as GenericEmbedBlockProps, HeaderBlock as HeaderBlockProps, + InlineMediaBlock as InlineMediaBlockProps, MediaBlock as MediaBlockProps, Page, Post, @@ -84,6 +87,7 @@ type NodeTypes = | SingleEventBlockProps | SponsorsBlockProps > + | SerializedInlineBlockNode const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { const { linkType, doc, url } = linkNode.fields @@ -136,6 +140,9 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) ), sponsorsBlock: ({ node }) => , }, + inlineBlocks: { + inlineMedia: ({ node }) => , + }, }) type Props = { diff --git a/src/fields/defaultLexical.ts b/src/fields/defaultLexical.ts index aa3afa8e1..3d931e3ee 100644 --- a/src/fields/defaultLexical.ts +++ b/src/fields/defaultLexical.ts @@ -1,5 +1,6 @@ import { BlogListBlock } from '@/blocks/BlogList/config' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/config' +import { InlineMediaBlock } from '@/blocks/InlineMedia/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' import { getTenantFilter } from '@/utilities/collectionFilters' import { validateExternalUrl } from '@/utilities/validateUrl' @@ -67,6 +68,7 @@ export const defaultLexical: Config['editor'] = lexicalEditor({ }), BlocksFeature({ blocks: [GenericEmbedBlock, BlogListBlock, SingleBlogPostBlock], + inlineBlocks: [InlineMediaBlock], }), FixedToolbarFeature(), OrderedListFeature(), diff --git a/src/payload-types.ts b/src/payload-types.ts index 59f510a23..913a04dc7 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -4172,6 +4172,32 @@ export interface CalloutBlock { blockName?: string | null; blockType: 'calloutBlock'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "InlineMediaBlock". + */ +export interface InlineMediaBlock { + media: number | Media; + /** + * Inline renders the image within the text flow. Float positions the image to one side with text wrapping around it. + */ + position?: ('inline' | 'float-left' | 'float-right') | null; + /** + * Vertical alignment relative to the surrounding text. + */ + verticalAlign?: ('middle' | 'top' | 'bottom' | 'baseline') | null; + /** + * Controls the maximum size of the image. When inline, this sets max height. When floating, this sets max width. + */ + size?: ('small' | 'medium' | 'large' | 'full') | null; + /** + * Optional text shown as a tooltip on hover. + */ + caption?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'inlineMedia'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". From 170000e9df1a2257cb1965205be7c47dd8a682ab Mon Sep 17 00:00:00 2001 From: Kellen Busby Date: Tue, 3 Mar 2026 12:37:17 -0800 Subject: [PATCH 2/4] Rework InlineMedia sizing to percentage widths, fixed height, and visible caption Replace small/medium/large presets with more flexible sizing options: original, 25/50/75/100% container width, and fixed pixel height. Caption now renders as visible text below the image instead of a tooltip. Co-Authored-By: Claude Opus 4.6 --- src/blocks/InlineMedia/Component.tsx | 73 ++++++++++++++++++++-------- src/blocks/InlineMedia/config.ts | 23 ++++++--- src/payload-types.ts | 8 ++- 3 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index 3fe15d9db..4ab6b98a6 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -5,18 +5,11 @@ import { cn } from '@/utilities/ui' type Props = Omit -const inlineSizeClasses = { - small: 'max-h-6', - medium: 'max-h-10', - large: 'max-h-16', - full: 'max-h-24', -} as const - -const floatSizeClasses = { - small: 'max-w-[8rem]', - medium: 'max-w-[12rem]', - large: 'max-w-[16rem]', - full: 'max-w-[24rem]', +const widthClasses = { + '25': 'w-1/4', + '50': 'w-1/2', + '75': 'w-3/4', + '100': 'w-full', } as const const verticalAlignClasses = { @@ -26,11 +19,18 @@ const verticalAlignClasses = { baseline: 'align-baseline', } as const +type WidthSize = keyof typeof widthClasses + +function isWidthSize(size: string): size is WidthSize { + return size in widthClasses +} + export const InlineMediaComponent = ({ media, position = 'inline', verticalAlign = 'middle', - size = 'small', + size = 'original', + fixedHeight, caption, }: Props) => { if (!media || typeof media === 'number' || typeof media === 'string') { @@ -38,21 +38,52 @@ export const InlineMediaComponent = ({ } const isFloat = position === 'float-left' || position === 'float-right' + const resolvedSize = size ?? 'original' - const sizeClass = isFloat ? floatSizeClasses[size ?? 'small'] : inlineSizeClasses[size ?? 'small'] + let sizeClass = '' + let imgSizeClass = 'w-auto h-auto' + let sizes = '100vw' + const isFixedHeight = resolvedSize === 'fixed-height' && fixedHeight + + if (resolvedSize === 'original') { + sizeClass = 'max-w-fit' + } else if (isWidthSize(resolvedSize)) { + sizeClass = widthClasses[resolvedSize] + imgSizeClass = 'w-full h-auto' + // Approximate sizes hint for responsive images + sizes = `${resolvedSize}vw` + } else if (isFixedHeight) { + imgSizeClass = 'h-full w-auto' + sizes = '96px' + } const positionClasses = isFloat ? cn(position === 'float-left' ? 'float-left mr-2' : 'float-right ml-2', 'mb-1') : cn('inline-block', verticalAlignClasses[verticalAlign ?? 'middle']) + // For fixed height, wrap Media in a span with explicit height. + // Descendant selectors propagate height through Media's intermediate span and picture elements. + const mediaElement = isFixedHeight ? ( + + + + ) : ( + + ) + return ( - - + + {mediaElement} + {caption && {caption}} ) } diff --git a/src/blocks/InlineMedia/config.ts b/src/blocks/InlineMedia/config.ts index 722e03260..e064ecc1b 100644 --- a/src/blocks/InlineMedia/config.ts +++ b/src/blocks/InlineMedia/config.ts @@ -42,16 +42,27 @@ export const InlineMediaBlock: Block = { { name: 'size', type: 'select', - defaultValue: 'small', + defaultValue: 'original', options: [ - { label: 'Small', value: 'small' }, - { label: 'Medium', value: 'medium' }, - { label: 'Large', value: 'large' }, - { label: 'Full', value: 'full' }, + { label: 'Original (natural size)', value: 'original' }, + { label: '25% width', value: '25' }, + { label: '50% width', value: '50' }, + { label: '75% width', value: '75' }, + { label: '100% width', value: '100' }, + { label: 'Fixed height', value: 'fixed-height' }, ], admin: { description: - 'Controls the maximum size of the image. When inline, this sets max height. When floating, this sets max width.', + 'Original uses the natural image size. Percentage widths are relative to the containing block. Fixed height lets you specify an exact pixel height.', + }, + }, + { + name: 'fixedHeight', + type: 'number', + min: 1, + admin: { + description: 'Height in pixels.', + condition: (_, siblingData) => siblingData?.size === 'fixed-height', }, }, { diff --git a/src/payload-types.ts b/src/payload-types.ts index 913a04dc7..c8b878f91 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -4187,9 +4187,13 @@ export interface InlineMediaBlock { */ verticalAlign?: ('middle' | 'top' | 'bottom' | 'baseline') | null; /** - * Controls the maximum size of the image. When inline, this sets max height. When floating, this sets max width. + * Original uses the natural image size. Percentage widths are relative to the containing block. Fixed height lets you specify an exact pixel height. */ - size?: ('small' | 'medium' | 'large' | 'full') | null; + size?: ('original' | '25' | '50' | '75' | '100' | 'fixed-height') | null; + /** + * Height in pixels. + */ + fixedHeight?: number | null; /** * Optional text shown as a tooltip on hover. */ From dd168471434ed71ae4f4b5f901df8c10e4738953 Mon Sep 17 00:00:00 2001 From: Kellen Busby Date: Tue, 3 Mar 2026 14:34:23 -0800 Subject: [PATCH 3/4] removing as const --- src/blocks/InlineMedia/Component.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index 4ab6b98a6..f15b2f726 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -10,14 +10,14 @@ const widthClasses = { '50': 'w-1/2', '75': 'w-3/4', '100': 'w-full', -} as const +} const verticalAlignClasses = { top: 'align-top', middle: 'align-middle', bottom: 'align-bottom', baseline: 'align-baseline', -} as const +} type WidthSize = keyof typeof widthClasses From 1903e72f46abfa98d56383ef71fec9a35517aea0 Mon Sep 17 00:00:00 2001 From: Kellen Busby Date: Tue, 3 Mar 2026 15:13:42 -0800 Subject: [PATCH 4/4] fixing types --- src/payload-types.ts | 1367 +++++++++++++++++++++++++++++++----------- 1 file changed, 1019 insertions(+), 348 deletions(-) diff --git a/src/payload-types.ts b/src/payload-types.ts index c8b878f91..7a6638278 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -163,9 +163,11 @@ export interface Config { a3Management: A3ManagementSelect | A3ManagementSelect; }; locale: null; - user: User & { - collection: 'users'; + widgets: { + 'getting-started': GettingStartedWidget; + collections: CollectionsWidget; }; + user: User; jobs: { tasks: unknown; workflows: unknown; @@ -256,23 +258,429 @@ export interface HomePage { * This is the body of your home page. This content will appear below the forecast zones map and the Highlighted Content section. */ layout: ( - | BlogListBlock - | ContentBlock - | DocumentBlock - | EventListBlock - | EventTableBlock - | FormBlock - | GenericEmbedBlock - | HeaderBlock - | ImageLinkGridBlock - | ImageTextBlock - | LinkPreviewBlock - | MediaBlock - | NACMediaBlock - | SingleBlogPostBlock - | SingleEventBlock - | SponsorsBlock - | TeamBlock + | { + heading?: string | null; + /** + * Optional content to display below the heading and above the blog list. + */ + belowHeadingContent?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + backgroundColor: string; + postOptions: 'dynamic' | 'static'; + dynamicOptions?: { + /** + * Select how the list of posts will be sorted. + */ + sortBy: '-publishedAt' | 'publishedAt'; + /** + * Optionally select tags to filter posts in this list by. + */ + filterByTags?: (number | Tag)[] | null; + /** + * Maximum number of posts that will be displayed. Must be an integer. + */ + maxPosts?: number | null; + }; + staticOptions?: { + /** + * Choose new post from dropdown and/or drag and drop to change order + */ + staticPosts?: (number | Post)[] | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'blogList'; + } + | { + backgroundColor: string; + layout: '1_1' | '2_11' | '3_111' | '2_12' | '2_21' | '4_1111' | '3_112' | '3_121' | '3_211'; + columns: { + richText?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + id?: string | null; + }[]; + id?: string | null; + blockName?: string | null; + blockType: 'content'; + } + | { + document: number | Document; + id?: string | null; + blockName?: string | null; + blockType: 'documentBlock'; + } + | { + backgroundColor: string; + heading?: string | null; + /** + * Optional content to display below the heading and above the event content. + */ + belowHeadingContent?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + eventOptions: 'dynamic' | 'static'; + /** + * Use Preview ↗ to see how events will appear + */ + dynamicOpts?: { + /** + * Optionally select event types to filter events. + */ + byTypes?: ('event' | 'awareness' | 'field-class')[] | null; + /** + * Optionally select event group to filter events. + */ + byGroups?: (number | EventGroup)[] | null; + /** + * Optionally select event tags to filter events. + */ + byTags?: (number | EventTag)[] | null; + /** + * Maximum number of events that will be displayed. Must be an integer. + */ + maxEvents?: number | null; + }; + staticOpts?: { + /** + * Choose new event from dropdown and/or drag and drop to change order + */ + staticEvents?: (number | Event)[] | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'eventList'; + } + | { + backgroundColor: string; + heading?: string | null; + /** + * Optional content to display below the heading and above the event content. + */ + belowHeadingContent?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + eventOptions: 'dynamic' | 'static'; + /** + * Use Preview ↗ to see how events will appear + */ + dynamicOpts?: { + /** + * Optionally select event types to filter events. + */ + byTypes?: ('event' | 'awareness' | 'field-class')[] | null; + /** + * Optionally select event group to filter events. + */ + byGroups?: (number | EventGroup)[] | null; + /** + * Optionally select event tags to filter events. + */ + byTags?: (number | EventTag)[] | null; + /** + * Maximum number of events that will be displayed. Must be an integer. + */ + maxEvents?: number | null; + }; + staticOpts?: { + /** + * Choose new event from dropdown and/or drag and drop to change order + */ + staticEvents?: (number | Event)[] | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'eventTable'; + } + | { + form: number | Form; + enableIntro?: boolean | null; + introContent?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + id?: string | null; + blockName?: string | null; + blockType: 'formBlock'; + } + | { + /** + * Helpful tip: