404
diff --git a/app/(frontend)/opengraph-image.tsx b/app/(frontend)/opengraph-image.tsx
index ded94746..cecb876f 100644
--- a/app/(frontend)/opengraph-image.tsx
+++ b/app/(frontend)/opengraph-image.tsx
@@ -1,120 +1,34 @@
-import { ImageResponse } from 'next/og'
+import { ImageResponse } from 'takumi-js/response'
import { cn } from '@/lib/cn'
-//
-// const interMedium = fetch(new URL('/fonts/pp-frama/pp-frama-500-normal.ttf', process.env.SERVER_URL)).then((res) =>
-// res.arrayBuffer(),
-// )
-//
-// const interRegular = fetch(new URL('/fonts/pp-frama-text/pp-frama-text-400-normal.ttf', process.env.SERVER_URL)).then((res) =>
-// res.arrayBuffer(),
-// )
+import { fetchGlobalUserSettingsCached, fetchSiteSettingsCached } from '@/lib/fetchers'
-export const alt = 'About Acme'
+export const alt = 'Website'
export const size = {
width: 1200,
height: 630,
}
+
export default async function Image() {
- // const [fontMedium, fontRegular] = await Promise.all([interMedium, interRegular])
- // const url = new URL(req.nextUrl)
- // const values = Object.fromEntries(url.searchParams)
- // const mode = (values.mode || 'light') as 'dark' | 'light'
- const mode = 'dark'
+ const {
+ general: { description },
+ } = await fetchSiteSettingsCached()
+ const { name } = await fetchGlobalUserSettingsCached()
return new ImageResponse(
-
-
-
- {/** biome-ignore lint/a11y/noSvgWithoutTitle:
*/}
-
-
-
Shadcn UI Blocks
-
-
-
- Beautifully Designed Shadcn UI Blocks
-
-
- Shadcn UI Blocks is a collection of beautifully designed block and components for your
- next project.
-
-
-
-
- {/** biome-ignore lint/performance/noImgElement:
*/}
-
-
+
{name}
+ {description && (
+
{description}
+ )}
,
{
width: 1200,
height: 630,
- // fonts: [
- // {
- // name: 'Inter',
- // data: fontMedium,
- // style: 'normal',
- // weight: 500,
- // },
- // {
- // name: 'Inter',
- // data: fontRegular,
- // style: 'normal',
- // weight: 400,
- // },
- // ],
},
)
}
diff --git a/app/(frontend)/resume/[slug]/opengraph-image.tsx b/app/(frontend)/resume/[slug]/opengraph-image.tsx
new file mode 100644
index 00000000..c76e4bc5
--- /dev/null
+++ b/app/(frontend)/resume/[slug]/opengraph-image.tsx
@@ -0,0 +1,36 @@
+import { notFound } from 'next/navigation'
+
+import { ImageResponse } from 'takumi-js/response'
+
+import { queryResumeDocumentBySlug } from './page'
+
+export const alt = 'Resume'
+export const size = {
+ width: 1200,
+ height: 630,
+}
+
+type Props = {
+ params: Promise<{
+ slug: string
+ }>
+}
+
+export default async function Image({ params }: Props) {
+ const { slug } = await params
+ const resume = await queryResumeDocumentBySlug(slug)
+ if (!resume) notFound()
+
+ return new ImageResponse(
+
+
Resume
+
+ {resume.title ?? 'Resume'}
+
+
,
+ {
+ width: 1200,
+ height: 630,
+ },
+ )
+}
diff --git a/app/(frontend)/resume/[slug]/page.tsx b/app/(frontend)/resume/[slug]/page.tsx
index 3413a167..40cbc72f 100644
--- a/app/(frontend)/resume/[slug]/page.tsx
+++ b/app/(frontend)/resume/[slug]/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from 'next'
import { cacheLife, cacheTag } from 'next/cache'
import config from '@payload-config'
import { getPayload } from 'payload'
@@ -62,7 +63,16 @@ export async function generateStaticParams() {
}))
}
-const queryResumeDocumentBySlug = async (slug: string) => {
+export async function generateMetadata({ params }: PageProps<'/resume/[slug]'>): Promise
{
+ const { slug } = await params
+ const resume = await queryResumeDocumentBySlug(slug)
+
+ return {
+ title: resume?.title ?? 'Resume',
+ }
+}
+
+export const queryResumeDocumentBySlug = async (slug: string) => {
'use cache'
cacheLife('max')
cacheTag(CollectionSlug.ResumeDocuments)
diff --git a/app/(payload)/admin/importMap.js b/app/(payload)/admin/importMap.js
index bf766210..9987f335 100644
--- a/app/(payload)/admin/importMap.js
+++ b/app/(payload)/admin/importMap.js
@@ -1,5 +1,6 @@
import { default as default_60b9f65365606d3c11eae014a9a986c5 } from '@/fields/Title/CellWithThumbnail'
import { default as default_f4e2381ac6c67e8deed93345830aa6f3 } from '@/fields/Slug/components/FieldComponent'
+import { default as default_66655c4d62aab79adfa093c97fdff25e } from '@/fields/HeroBackground/Components/FieldComponent'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
@@ -37,6 +38,7 @@ import { ImportListMenuItem as ImportListMenuItem_cdf7e044479f899a31f804427d568b
import { default as default_739727f92d93200cec032c1e61a6cb15 } from '@/fields/Toggle/components/FieldComponent'
import { default as default_22b4891c4f5fa760ee9f626882377967 } from '@/fields/Protected/components/FieldComponent'
import { AltField as AltField_24f51e17ebe211ea3e1b6c287f79c7cc } from '@/collections/MediaImages/components'
+import { UnsplashSearch as UnsplashSearch_5e5cb6c43e4b5799bbd5217d983bc5d0 } from '@/components/AdminPanel/UnsplashSearch'
import { MediaScopeTabs as MediaScopeTabs_4629bc42a060e6d23d7d57d776e5da7b } from '@/components/AdminPanel/MediaScopeTabs'
import { default as default_390597f1ad60ab95e5b4fbebc846ccb5 } from '@/fields/SVGUpload/components/FieldComponent'
import { default as default_ee50e315ea91373bea7c5cab35816793 } from '@/fields/BilingualRichText/components/TranslateControls'
@@ -77,6 +79,7 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } f
export const importMap = {
"@/fields/Title/CellWithThumbnail#default": default_60b9f65365606d3c11eae014a9a986c5,
"@/fields/Slug/components/FieldComponent#default": default_f4e2381ac6c67e8deed93345830aa6f3,
+ "@/fields/HeroBackground/Components/FieldComponent#default": default_66655c4d62aab79adfa093c97fdff25e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
@@ -114,6 +117,7 @@ export const importMap = {
"@/fields/Toggle/components/FieldComponent#default": default_739727f92d93200cec032c1e61a6cb15,
"@/fields/Protected/components/FieldComponent#default": default_22b4891c4f5fa760ee9f626882377967,
"@/collections/MediaImages/components#AltField": AltField_24f51e17ebe211ea3e1b6c287f79c7cc,
+ "@/components/AdminPanel/UnsplashSearch#UnsplashSearch": UnsplashSearch_5e5cb6c43e4b5799bbd5217d983bc5d0,
"@/components/AdminPanel/MediaScopeTabs#MediaScopeTabs": MediaScopeTabs_4629bc42a060e6d23d7d57d776e5da7b,
"@/fields/SVGUpload/components/FieldComponent#default": default_390597f1ad60ab95e5b4fbebc846ccb5,
"@/fields/BilingualRichText/components/TranslateControls#default": default_ee50e315ea91373bea7c5cab35816793,
diff --git a/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.test.tsx b/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.test.tsx
index b1973b2f..0983a2a3 100644
--- a/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.test.tsx
+++ b/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.test.tsx
@@ -74,7 +74,7 @@ describe('TrendingBlogPostsBlockRenderer', () => {
expect(screen.getByText('Trending Now')).toBeInTheDocument()
})
- it('does not render an image when heroImage.value.url is null', async () => {
+ it('does not render an image when hero.background.media.value.url is null', async () => {
fetchTrendingBlogPostsMock.mockResolvedValue([
{
slug: 'no-hero-url',
@@ -83,12 +83,17 @@ describe('TrendingBlogPostsBlockRenderer', () => {
id: 'no-hero-url',
title: 'No Hero URL',
slug: 'no-hero-url',
- heroImage: {
- relationTo: 'images',
- value: {
- id: 'media-1',
- alt: 'Alt text',
- url: null,
+ hero: {
+ background: {
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: {
+ id: 'media-1',
+ alt: 'Alt text',
+ url: null,
+ },
+ },
},
},
} as unknown as TrendingBlogPost['post'],
@@ -102,7 +107,7 @@ describe('TrendingBlogPostsBlockRenderer', () => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
- it('does not render an image when heroImage.value.url is an empty string', async () => {
+ it('does not render an image when hero.background.media.value.url is an empty string', async () => {
fetchTrendingBlogPostsMock.mockResolvedValue([
{
slug: 'empty-hero-url',
@@ -111,12 +116,17 @@ describe('TrendingBlogPostsBlockRenderer', () => {
id: 'empty-hero-url',
title: 'Empty Hero URL',
slug: 'empty-hero-url',
- heroImage: {
- relationTo: 'images',
- value: {
- id: 'media-2',
- alt: 'Alt text',
- url: '',
+ hero: {
+ background: {
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: {
+ id: 'media-2',
+ alt: 'Alt text',
+ url: '',
+ },
+ },
},
},
} as unknown as TrendingBlogPost['post'],
diff --git a/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.tsx b/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.tsx
index 3c025b17..9cdc3e3c 100644
--- a/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.tsx
+++ b/src/blocks/TrendingBlogPostsBlock/Renderer/Renderer.tsx
@@ -41,7 +41,11 @@ export const TrendingBlogPostsBlockRenderer = async ({
)}
{posts.map(({ slug, post }) => {
- const heroImage = isRenderableImage(post.heroImage?.value) ? post.heroImage.value : null
+ const backgroundMedia =
+ post.hero?.background?.backgroundType === 'media'
+ ? post.hero.background.media
+ : undefined
+ const heroImage = isRenderableImage(backgroundMedia?.value) ? backgroundMedia.value : null
return (
= {
fields: [
/* -------------- Main Content -------------- */
TitleField({
- listViewThumbnailPath: 'heroImage',
+ listViewThumbnailPath: 'hero.background.media.value',
}),
/* -------------- Sidebar Content -------------- */
@@ -79,13 +79,23 @@ export const BlogPosts: CollectionConfig
= {
SlugField({
fieldToUse: 'title',
}),
- MediaField({
- name: 'heroImage',
- relationTo: [
- CollectionSlug.MediaImages,
+ {
+ name: 'hero',
+ type: 'group',
+ label: false,
+ admin: {
+ position: 'sidebar',
+ disableListColumn: true,
+ disableListFilter: true,
+ disableGroupBy: true,
+ },
+ fields: [
+ HeroBackgroundField({
+ name: 'background',
+ hasManyMedia: false,
+ }),
],
- position: 'sidebar',
- }),
+ },
{
name: 'topics',
type: 'relationship',
diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts
index 20ad8504..80300634 100644
--- a/src/collections/Pages/index.ts
+++ b/src/collections/Pages/index.ts
@@ -7,6 +7,7 @@ import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
import { BLOCK_SLUGS } from '@/blocks'
import { revalidatePage } from '@/collections/Pages/hooks/revalidatePage'
import { GeneratorFlagsField } from '@/fields/GeneratorFlags'
+import { HeroBackgroundField } from '@/fields/HeroBackground'
import { MetaField } from '@/fields/Meta'
import { ProtectedField } from '@/fields/Protected'
import { RichTextField } from '@/fields/RichText'
@@ -77,7 +78,7 @@ export const Pages: CollectionConfig = {
fields: [
/* -------------- Main Content -------------- */
TitleField({
- listViewThumbnailPath: 'hero.media.0',
+ listViewThumbnailPath: 'hero.background.media.0.value',
}),
/* -------------- Sidebar Content -------------- */
@@ -124,24 +125,9 @@ export const Pages: CollectionConfig = {
label: 'Hero',
name: 'hero',
fields: [
- {
- name: 'media',
- type: 'upload',
- relationTo: [
- CollectionSlug.MediaImages,
- CollectionSlug.MediaVideos,
- ],
- hasMany: true,
- displayPreview: true,
- label: false,
- admin: {
- description:
- 'Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own.',
- disableListColumn: true,
- disableListFilter: true,
- disableGroupBy: true,
- },
- },
+ HeroBackgroundField({
+ name: 'background',
+ }),
{
name: 'contentType',
type: 'select',
diff --git a/src/components/HeroMedia/HeroMedia.test.ts b/src/components/HeroMedia/HeroMedia.test.tsx
similarity index 69%
rename from src/components/HeroMedia/HeroMedia.test.ts
rename to src/components/HeroMedia/HeroMedia.test.tsx
index 89682ac8..d3af64d1 100644
--- a/src/components/HeroMedia/HeroMedia.test.ts
+++ b/src/components/HeroMedia/HeroMedia.test.tsx
@@ -1,6 +1,14 @@
-import { describe, expect, it } from 'vitest'
+// @vitest-environment jsdom
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
-import { toItems } from './HeroMedia'
+import { HeroMedia, toItems } from './HeroMedia'
+
+vi.mock('./ShaderHeroBackground', () => ({
+ ShaderHeroBackground: ({ presetKey }: { presetKey: string }) => (
+
+ ),
+}))
const image = {
relationTo: 'images',
@@ -206,3 +214,54 @@ describe('toItems', () => {
})
})
})
+
+describe('HeroMedia', () => {
+ it('renders nothing visual when backgroundType is media with no media', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('shader-hero-background')).not.toBeInTheDocument()
+ })
+
+ it('renders the shader background when backgroundType is shader', () => {
+ render(
+ ,
+ )
+
+ const shaderEl = screen.getByTestId('shader-hero-background')
+ expect(shaderEl).toBeInTheDocument()
+ expect(shaderEl).toHaveAttribute('data-preset', 'darkveil')
+ })
+
+ it('does not render a shader background when backgroundType is shader but no shader is selected', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('shader-hero-background')).not.toBeInTheDocument()
+ })
+
+ it('falls back to media rendering when background is legacy/undefined (pre-migration content)', () => {
+ // Documents saved before this field existed have no `background` key at
+ // all — only the old flat `media` shape. HeroMedia must not crash.
+ render()
+
+ expect(screen.queryByTestId('shader-hero-background')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/HeroMedia/HeroMedia.tsx b/src/components/HeroMedia/HeroMedia.tsx
index aaadc10a..bd4312ba 100644
--- a/src/components/HeroMedia/HeroMedia.tsx
+++ b/src/components/HeroMedia/HeroMedia.tsx
@@ -6,10 +6,12 @@ import type { MediaImage, MediaVideo } from '@/types/payload'
import { HeroCarousel } from './HeroCarousel'
import type { HeroMediaItem } from './HeroSlide'
+import { ShaderHeroBackground } from './ShaderHeroBackground'
+import type { ShaderPresetKey } from './shaderPresets'
export interface HeroMediaProps {
- /** Polymorphic upload references straight from Payload. */
- media: unknown
+ /** The whole `hero.background` group value: `{ backgroundType, media, shader }`. */
+ background: unknown
/** Falls back to this when an asset carries no alt text of its own. */
fallbackAlt?: string
/**
@@ -102,21 +104,50 @@ export const toItems = (media: unknown, fallbackAlt: string): HeroMediaItem[] =>
})
}
+/** Narrows an unknown `background` group value to its recognized shape. */
+const parseBackground = (
+ background: unknown,
+): {
+ backgroundType: 'media' | 'shader'
+ media: unknown
+ shader: ShaderPresetKey | undefined
+} => {
+ if (typeof background !== 'object' || background === null) {
+ return {
+ backgroundType: 'media',
+ media: undefined,
+ shader: undefined,
+ }
+ }
+
+ const record = background as Record
+ const backgroundType = record.backgroundType === 'shader' ? 'shader' : 'media'
+
+ return {
+ backgroundType,
+ media: record.media,
+ shader: typeof record.shader === 'string' ? (record.shader as ShaderPresetKey) : undefined,
+ }
+}
+
/**
- * Full-bleed hero visual: one asset, or a cross-fading carousel of several,
- * under the brand duotone treatment.
+ * Full-bleed hero visual: one asset, a cross-fading carousel of several, or
+ * a live shader background, under the brand duotone treatment.
*
* `children` render above the visual — that is where the headline, topics and
* meta go.
*/
export const HeroMedia = ({
- media,
+ background,
fallbackAlt = '',
align = 'bottom',
className,
children,
}: HeroMediaProps) => {
- const items = toItems(media, fallbackAlt)
+ const { backgroundType, media, shader } = parseBackground(background)
+ const items = backgroundType === 'media' ? toItems(media, fallbackAlt) : []
+ const hasShader = backgroundType === 'shader' && Boolean(shader)
+ const hasVisual = items.length > 0 || hasShader
return (
- {items.length > 0 && (
+ {hasVisual && (
- {items.length === 1 && items[0].kind === 'image' ? (
+ {hasShader && shader ? (
+
+ ) : items.length === 1 && items[0].kind === 'image' ? (
> = {
+ darkveil: dynamic(() => import('@/components/Shaders/DarkVeil'), {
+ ssr: false,
+ }),
+ 'faulty-terminal': dynamic(() => import('@/components/Shaders/FaultyTerminal'), {
+ ssr: false,
+ }),
+ 'gradient-blinds': dynamic(() => import('@/components/Shaders/GradientBlinds'), {
+ ssr: false,
+ }),
+ grainient: dynamic(() => import('@/components/Shaders/Grainient'), {
+ ssr: false,
+ }),
+}
+
+/**
+ * The same curated per-preset prop bundles as the admin picker's
+ * `SHADER_COMPONENTS` (`src/fields/HeroBackground/Components/shaderComponents.ts`)
+ * — intentionally duplicated rather than shared, since that file lives on
+ * the admin-only client bundle and this one lives on the public frontend
+ * bundle; sharing it would pull admin-picker code into the public bundle
+ * for no benefit. Keep the two in sync by hand if a preset's tuning changes.
+ */
+const SHADER_PROPS: Record> = {
+ darkveil: {
+ hueShift: 0,
+ noiseIntensity: 0.02,
+ scanlineIntensity: 0,
+ speed: 0.3,
+ warpAmount: 0.1,
+ },
+ 'faulty-terminal': {
+ tint: '#3dff8f',
+ scanlineIntensity: 0.2,
+ glitchAmount: 0.5,
+ flickerAmount: 0.3,
+ brightness: 0.6,
+ mouseReact: false,
+ pageLoadAnimation: false,
+ },
+ 'gradient-blinds': {
+ gradientColors: [
+ '#FF9FFC',
+ '#5227FF',
+ ],
+ angle: 15,
+ noise: 0.15,
+ blindCount: 12,
+ mouseDampening: 0.15,
+ },
+ grainient: {
+ color1: '#FF9FFC',
+ color2: '#5227FF',
+ color3: '#B497CF',
+ timeSpeed: 0.15,
+ grainAmount: 0.06,
+ },
+}
+
+export interface ShaderHeroBackgroundProps {
+ presetKey: ShaderPresetKey
+ className?: string
+}
+
+/**
+ * Renders the given shader preset full-bleed, live. Dynamically imported
+ * (`ssr: false`) since the shader components read `window` at
+ * default-parameter-evaluation time, not just inside effects — a static
+ * import here would crash server rendering.
+ */
+export const ShaderHeroBackground = ({ presetKey, className }: ShaderHeroBackgroundProps) => {
+ const Shader = SHADER_COMPONENTS[presetKey]
+
+ return (
+
+
+
+ )
+}
+
+export default ShaderHeroBackground
diff --git a/src/components/HeroMedia/shaderPresets.ts b/src/components/HeroMedia/shaderPresets.ts
new file mode 100644
index 00000000..89917e66
--- /dev/null
+++ b/src/components/HeroMedia/shaderPresets.ts
@@ -0,0 +1,50 @@
+export type ShaderPresetKey = 'darkveil' | 'faulty-terminal' | 'gradient-blinds' | 'grainient'
+
+export type ShaderPreset = {
+ key: ShaderPresetKey
+ label: string
+ /**
+ * A static CSS gradient approximating this preset's real on-screen look,
+ * for contexts that cannot run WebGL: OG images (Takumi/next-og cannot
+ * execute canvas/WebGL) and the blog list view's card thumbnails.
+ *
+ * DarkVeil has no color props of its own (its look comes from an
+ * intrinsic CPPN pattern generator) — its gradient here is a hand-picked
+ * pair evoking its actual dark, moody on-screen appearance, not derived
+ * from any prop value. The other three presets' gradients are derived
+ * from their real preset color props (see Task 3/4's live-component
+ * prop bundles) — kept as plain literal strings here, not computed, so
+ * this file has zero dependency on the shader components themselves.
+ */
+ gradient: string
+}
+
+export const SHADER_PRESETS: ShaderPreset[] = [
+ {
+ key: 'darkveil',
+ label: 'Dark Veil',
+ gradient: 'linear-gradient(135deg, #0a0a12 0%, #1a1030 100%)',
+ },
+ {
+ key: 'faulty-terminal',
+ label: 'Faulty Terminal',
+ gradient: 'linear-gradient(135deg, #001a0a 0%, #003d1a 100%)',
+ },
+ {
+ key: 'gradient-blinds',
+ label: 'Gradient Blinds',
+ gradient: 'linear-gradient(135deg, #ff9ffc 0%, #5227ff 100%)',
+ },
+ {
+ key: 'grainient',
+ label: 'Grainient',
+ gradient: 'linear-gradient(135deg, #ff9ffc 0%, #5227ff 50%, #b497cf 100%)',
+ },
+]
+
+export const SHADER_PRESET_MAP: Record = Object.fromEntries(
+ SHADER_PRESETS.map((preset) => [
+ preset.key,
+ preset,
+ ]),
+) as Record
diff --git a/src/fields/HeroBackground/Components/FieldComponent.tsx b/src/fields/HeroBackground/Components/FieldComponent.tsx
new file mode 100644
index 00000000..fae2f0f7
--- /dev/null
+++ b/src/fields/HeroBackground/Components/FieldComponent.tsx
@@ -0,0 +1,77 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import type { SelectFieldClientProps } from 'payload'
+import { Button, FieldError, FieldLabel, useDrawerSlug, useField, useModal } from '@payloadcms/ui'
+
+import { SHADER_PRESET_MAP, type ShaderPresetKey } from '@/components/HeroMedia/shaderPresets'
+
+import { SHADER_COMPONENTS } from './shaderComponents'
+
+const ShaderPickerDrawer = dynamic(() => import('./ShaderPickerDrawer'), {
+ ssr: false,
+})
+const ShaderPreviewCanvas = dynamic(() => import('./ShaderPreviewCanvas'), {
+ ssr: false,
+})
+
+/**
+ * Payload admin control for `HeroBackgroundField`'s `shader` sub-field.
+ * Shows the current selection (label + a small live preview) and a button
+ * to open the full picker drawer.
+ *
+ * Structurally modeled on `src/fields/Icon/Field.tsx` — `useField` for the
+ * value, `useModal`/`useDrawerSlug` to open a `Drawer`, pick-and-close.
+ */
+const HeroBackgroundShaderField = ({ path, field, readOnly }: SelectFieldClientProps) => {
+ const { value, setValue, showError, errorMessage } = useField({
+ path,
+ })
+ const { openModal } = useModal()
+ const drawerSlug = useDrawerSlug(`hero-shader-picker-${path}`)
+
+ const selectedPreset = value ? SHADER_PRESET_MAP[value] : undefined
+
+ return (
+
+
+
+
+ {selectedPreset ? (
+
+
+
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+ {!readOnly && (
+
setValue(key)}
+ />
+ )}
+
+
+
+ )
+}
+
+export default HeroBackgroundShaderField
diff --git a/src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx b/src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx
new file mode 100644
index 00000000..0ed719eb
--- /dev/null
+++ b/src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import { Drawer, useModal } from '@payloadcms/ui'
+
+import { SHADER_PRESETS, type ShaderPresetKey } from '@/components/HeroMedia/shaderPresets'
+
+import { SHADER_COMPONENTS } from './shaderComponents'
+
+const ShaderPreviewCanvas = dynamic(() => import('./ShaderPreviewCanvas'), {
+ ssr: false,
+})
+
+export interface ShaderPickerDrawerProps {
+ slug: string
+ value?: ShaderPresetKey
+ onSelectAction: (key: ShaderPresetKey) => void
+}
+
+/**
+ * Grid of preset cards inside Payload's Drawer, each rendering its shader
+ * live — not a static screenshot. Click a card to select it and close the
+ * drawer, matching Payload's own block-picker's pick-and-close flow.
+ *
+ * Running up to 4 live WebGL contexts at once here is intentional (per the
+ * design's confirmed choice) — dynamic-imported with `ssr: false` since the
+ * shader components touch `window` outside of any effect.
+ */
+export const ShaderPickerDrawer = ({ slug, value, onSelectAction }: ShaderPickerDrawerProps) => {
+ const { closeModal } = useModal()
+
+ return (
+
+
+ {SHADER_PRESETS.map((preset) => (
+
+ ))}
+
+
+ )
+}
+
+export default ShaderPickerDrawer
diff --git a/src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx b/src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx
new file mode 100644
index 00000000..3bb1dea8
--- /dev/null
+++ b/src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import type { ShaderComponentEntry } from './shaderComponents'
+
+export type ShaderPreviewCanvasProps = {
+ entry: ShaderComponentEntry
+ className?: string
+}
+
+/**
+ * Renders one shader preset live, sized to fill its container. Used both by
+ * the picker drawer's grid (many small instances at once) and the field's
+ * own "currently selected" summary (one instance).
+ *
+ * The shader components read `window` at default-parameter-evaluation time,
+ * not just inside effects — this file must only ever be reached through a
+ * `next/dynamic(..., { ssr: false })` boundary (see FieldComponent.tsx),
+ * never imported statically into anything that could render on the server.
+ */
+export const ShaderPreviewCanvas = ({ entry, className }: ShaderPreviewCanvasProps) => {
+ const { Component, props } = entry
+
+ return (
+
+
+
+ )
+}
+
+export default ShaderPreviewCanvas
diff --git a/src/fields/HeroBackground/Components/shaderComponents.ts b/src/fields/HeroBackground/Components/shaderComponents.ts
new file mode 100644
index 00000000..b61fa8f8
--- /dev/null
+++ b/src/fields/HeroBackground/Components/shaderComponents.ts
@@ -0,0 +1,84 @@
+import type { ShaderPresetKey } from '@/components/HeroMedia/shaderPresets'
+import DarkVeil, { type DarkVeilProps } from '@/components/Shaders/DarkVeil'
+import FaultyTerminal, { type FaultyTerminalProps } from '@/components/Shaders/FaultyTerminal'
+import GradientBlinds, { type GradientBlindsProps } from '@/components/Shaders/GradientBlinds'
+import Grainient, { type GrainientProps } from '@/components/Shaders/Grainient'
+
+export type ShaderComponentEntry =
+ | {
+ key: 'darkveil'
+ Component: typeof DarkVeil
+ props: DarkVeilProps
+ }
+ | {
+ key: 'faulty-terminal'
+ Component: typeof FaultyTerminal
+ props: FaultyTerminalProps
+ }
+ | {
+ key: 'gradient-blinds'
+ Component: typeof GradientBlinds
+ props: GradientBlindsProps
+ }
+ | {
+ key: 'grainient'
+ Component: typeof Grainient
+ props: GrainientProps
+ }
+
+/**
+ * One curated, fixed prop bundle per shader — no per-prop admin tuning.
+ * Values chosen to look good as a full-bleed hero background at rest (slow
+ * motion, low noise) rather than each shader's own demo/showcase defaults.
+ */
+export const SHADER_COMPONENTS: Record = {
+ darkveil: {
+ key: 'darkveil',
+ Component: DarkVeil,
+ props: {
+ hueShift: 0,
+ noiseIntensity: 0.02,
+ scanlineIntensity: 0,
+ speed: 0.3,
+ warpAmount: 0.1,
+ },
+ },
+ 'faulty-terminal': {
+ key: 'faulty-terminal',
+ Component: FaultyTerminal,
+ props: {
+ tint: '#3dff8f',
+ scanlineIntensity: 0.2,
+ glitchAmount: 0.5,
+ flickerAmount: 0.3,
+ brightness: 0.6,
+ mouseReact: false,
+ pageLoadAnimation: false,
+ },
+ },
+ 'gradient-blinds': {
+ key: 'gradient-blinds',
+ Component: GradientBlinds,
+ props: {
+ gradientColors: [
+ '#FF9FFC',
+ '#5227FF',
+ ],
+ angle: 15,
+ noise: 0.15,
+ blindCount: 12,
+ mouseDampening: 0.15,
+ },
+ },
+ grainient: {
+ key: 'grainient',
+ Component: Grainient,
+ props: {
+ color1: '#FF9FFC',
+ color2: '#5227FF',
+ color3: '#B497CF',
+ timeSpeed: 0.15,
+ grainAmount: 0.06,
+ },
+ },
+}
diff --git a/src/fields/HeroBackground/index.test.ts b/src/fields/HeroBackground/index.test.ts
new file mode 100644
index 00000000..ed118363
--- /dev/null
+++ b/src/fields/HeroBackground/index.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest'
+
+import { HeroBackgroundField } from './index'
+
+describe('HeroBackgroundField', () => {
+ it('builds a group field with the given name', () => {
+ const field = HeroBackgroundField({
+ name: 'background',
+ })
+
+ expect(field.type).toBe('group')
+ expect(field.name).toBe('background')
+ })
+
+ it('includes a backgroundType select defaulting to media', () => {
+ const field = HeroBackgroundField({
+ name: 'background',
+ })
+ const backgroundType = field.fields.find(
+ (
+ f,
+ ): f is Extract<
+ typeof f,
+ {
+ type: 'select'
+ }
+ > => 'name' in f && f.name === 'backgroundType',
+ )
+
+ expect(backgroundType).toBeDefined()
+ expect(backgroundType?.type).toBe('select')
+ expect(backgroundType?.defaultValue).toBe('media')
+ })
+
+ it('includes a media upload field allowing images and videos', () => {
+ const field = HeroBackgroundField({
+ name: 'background',
+ })
+ const media = field.fields.find(
+ (
+ f,
+ ): f is Extract<
+ typeof f,
+ {
+ type: 'upload'
+ }
+ > => 'name' in f && f.name === 'media',
+ )
+
+ expect(media).toBeDefined()
+ expect(media?.type).toBe('upload')
+ expect(media?.relationTo).toEqual([
+ 'images',
+ 'videos',
+ ])
+ expect(media?.hasMany).toBe(true)
+ })
+
+ it('includes a shader select field registered with the custom admin component', () => {
+ const field = HeroBackgroundField({
+ name: 'background',
+ })
+ const shader = field.fields.find(
+ (
+ f,
+ ): f is Extract<
+ typeof f,
+ {
+ type: 'select'
+ }
+ > => 'name' in f && f.name === 'shader',
+ )
+
+ expect(shader).toBeDefined()
+ expect(shader?.type).toBe('select')
+ expect(shader?.admin?.components?.Field).toMatchObject({
+ path: '@/fields/HeroBackground/Components/FieldComponent',
+ })
+ })
+
+ it('accepts a hasMany override for the media field (BlogPosts stays single)', () => {
+ const field = HeroBackgroundField({
+ name: 'background',
+ hasManyMedia: false,
+ })
+ const media = field.fields.find(
+ (
+ f,
+ ): f is Extract<
+ typeof f,
+ {
+ type: 'upload'
+ }
+ > => 'name' in f && f.name === 'media',
+ )
+
+ expect(media?.hasMany).toBe(false)
+ })
+})
diff --git a/src/fields/HeroBackground/index.ts b/src/fields/HeroBackground/index.ts
new file mode 100644
index 00000000..518d4362
--- /dev/null
+++ b/src/fields/HeroBackground/index.ts
@@ -0,0 +1,99 @@
+import type { NamedGroupField } from 'payload'
+
+import { SHADER_PRESETS } from '@/components/HeroMedia/shaderPresets'
+import { CollectionSlug } from '@/types/collections'
+
+type HeroBackgroundFieldOptions = {
+ name: string
+ /** Pages' hero.media is a carousel (hasMany); BlogPosts' stays single. Defaults to true (Pages' existing behavior). */
+ hasManyMedia?: boolean
+}
+
+/**
+ * A hero background choosable as either uploaded media (image/video,
+ * optionally a carousel) or a curated WebGL shader preset — an alternative
+ * for `Pages`/`BlogPosts`' full-bleed hero.
+ *
+ * `shader`'s admin control is a custom field component (see
+ * `./Components/FieldComponent`) — a drawer of live-rendering preset cards,
+ * not Payload's default select dropdown. This field-config module must
+ * never import that component directly (only its path string, via
+ * `admin.components.Field`) — `payload generate:types` runs under plain
+ * Node, and a transitive `@payloadcms/ui` import fails there on
+ * bundler-only `.css` imports. See `src/fields/Icon/index.ts` for the
+ * identical established pattern.
+ */
+export const HeroBackgroundField = ({
+ name,
+ hasManyMedia = true,
+}: HeroBackgroundFieldOptions): NamedGroupField => ({
+ name,
+ type: 'group',
+ label: false,
+ admin: {
+ disableListColumn: true,
+ disableListFilter: true,
+ disableGroupBy: true,
+ },
+ fields: [
+ {
+ name: 'backgroundType',
+ type: 'select',
+ defaultValue: 'media',
+ options: [
+ {
+ label: 'Uploaded Media',
+ value: 'media',
+ },
+ {
+ label: 'Shader',
+ value: 'shader',
+ },
+ ],
+ admin: {
+ disableListColumn: true,
+ disableListFilter: true,
+ disableGroupBy: true,
+ },
+ },
+ {
+ name: 'media',
+ type: 'upload',
+ relationTo: [
+ CollectionSlug.MediaImages,
+ CollectionSlug.MediaVideos,
+ ],
+ hasMany: hasManyMedia,
+ displayPreview: true,
+ label: false,
+ admin: {
+ condition: (_, siblingData) => siblingData?.backgroundType !== 'shader',
+ description: hasManyMedia
+ ? 'Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own.'
+ : 'Fills the first screen.',
+ disableListColumn: true,
+ disableListFilter: true,
+ disableGroupBy: true,
+ },
+ },
+ {
+ name: 'shader',
+ type: 'select',
+ options: SHADER_PRESETS.map((preset) => ({
+ label: preset.label,
+ value: preset.key,
+ })),
+ admin: {
+ condition: (_, siblingData) => siblingData?.backgroundType === 'shader',
+ disableListColumn: true,
+ disableListFilter: true,
+ disableGroupBy: true,
+ components: {
+ Field: {
+ path: '@/fields/HeroBackground/Components/FieldComponent',
+ },
+ },
+ },
+ },
+ ],
+})
diff --git a/src/lib/generateMeta.ts b/src/lib/generateMeta.ts
index dfcea3ec..c59df1b3 100644
--- a/src/lib/generateMeta.ts
+++ b/src/lib/generateMeta.ts
@@ -14,15 +14,16 @@ interface GenerateMetaArgs {
export const generateMeta = async ({ doc }: GenerateMetaArgs): Promise => {
const {
- general: { description, category },
+ general: { description, category, siteName },
} = await fetchSiteSettingsCached()
const { name, url } = await fetchGlobalUserSettingsCached()
- // const ogImage = getImageURL(doc?.meta?.image)
+ const title = doc?.meta?.title || doc?.title
+ const metaDescription = doc?.meta?.description || description
return {
- title: doc?.meta?.title || doc?.title,
- description: doc?.meta?.description || description,
+ title,
+ description: metaDescription,
category,
creator: name,
authors: [
@@ -31,10 +32,14 @@ export const generateMeta = async ({ doc }: GenerateMetaArgs): Promise
url,
},
],
- // openGraph: {
- // type: 'website',
- // title: titleGenerator({ title: doc.title || '', siteName }),
- // description,
- // },
+ // `images` is deliberately omitted: Next auto-discovers each route's
+ // sibling opengraph-image.tsx and infers its URL, so hand-computed image
+ // URLs here cannot drift out of sync with the generated images.
+ openGraph: {
+ type: 'website',
+ title,
+ description: metaDescription,
+ siteName: siteName ?? undefined,
+ },
}
}
diff --git a/src/lib/seed/pages.test.ts b/src/lib/seed/pages.test.ts
index 5c1a2aa8..af98f104 100644
--- a/src/lib/seed/pages.test.ts
+++ b/src/lib/seed/pages.test.ts
@@ -26,7 +26,7 @@ beforeEach(() => {
})
describe('seedPages', () => {
- it('creates the requested number of pages, each with a tagged hero image', async () => {
+ it('creates the requested number of pages, each with a tagged hero background', async () => {
find.mockResolvedValue({
docs: [],
})
@@ -44,8 +44,82 @@ describe('seedPages', () => {
const result = await seedPages(makePayload(), 3)
expect(result.created).toBe(3)
- // one image create + one page create per iteration
- expect(create).toHaveBeenCalledTimes(6)
+ // one page create per iteration, plus one image create per
+ // media-backed page (shader-backed pages skip the image entirely)
+ const pageCreates = create.mock.calls.filter(([args]) => args.collection === 'pages')
+ const imageCreates = create.mock.calls.filter(([args]) => args.collection === 'images')
+ expect(pageCreates).toHaveLength(3)
+ expect(create).toHaveBeenCalledTimes(3 + imageCreates.length)
+ })
+
+ it('gives a shader-backed hero the correct background shape when the shader branch is chosen', async () => {
+ find.mockResolvedValue({
+ docs: [],
+ })
+ const heroBackgrounds: unknown[] = []
+ create.mockImplementation(async ({ collection, data }) => {
+ if (collection === 'images') {
+ return {
+ id: 'image-1',
+ }
+ }
+ heroBackgrounds.push(data.hero.background)
+ return {
+ id: 'page-1',
+ }
+ })
+
+ // A larger batch makes it overwhelmingly likely (with chance(random,
+ // 0.25) seeded per-slug) that at least one page lands on the shader
+ // branch, without mocking the randomness source directly.
+ await seedPages(makePayload(), 20)
+
+ const shaderBackgrounds = heroBackgrounds.filter(
+ (
+ background,
+ ): background is {
+ backgroundType: string
+ shader: string
+ } =>
+ (
+ background as {
+ backgroundType: string
+ }
+ ).backgroundType === 'shader',
+ )
+ const mediaBackgrounds = heroBackgrounds.filter(
+ (background) =>
+ (
+ background as {
+ backgroundType: string
+ }
+ ).backgroundType === 'media',
+ )
+
+ expect(shaderBackgrounds.length).toBeGreaterThan(0)
+ expect(mediaBackgrounds.length).toBeGreaterThan(0)
+
+ for (const background of shaderBackgrounds) {
+ expect(background).toEqual({
+ backgroundType: 'shader',
+ shader: expect.any(String),
+ })
+ }
+ for (const background of mediaBackgrounds) {
+ expect(background).toEqual({
+ backgroundType: 'media',
+ media: [
+ {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ ],
+ })
+ }
+
+ // Image creation is skipped entirely for shader-backed pages.
+ const imageCreates = create.mock.calls.filter(([args]) => args.collection === 'images')
+ expect(imageCreates).toHaveLength(mediaBackgrounds.length)
})
it('skips a slug that already exists instead of creating a duplicate', async () => {
@@ -174,12 +248,14 @@ describe('cleanPages', () => {
{
id: 'page-1',
hero: {
- media: [
- {
- relationTo: 'images',
- value: 'image-1',
- },
- ],
+ background: {
+ media: [
+ {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ ],
+ },
},
},
],
@@ -248,12 +324,14 @@ describe('cleanPages', () => {
{
id: 'page-1',
hero: {
- media: [
- {
- relationTo: 'images',
- value: 'image-1',
- },
- ],
+ background: {
+ media: [
+ {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ ],
+ },
},
},
],
@@ -292,7 +370,9 @@ describe('cleanPages', () => {
{
id: 'page-1',
hero: {
- media: [],
+ background: {
+ media: [],
+ },
},
},
],
diff --git a/src/lib/seed/pages.ts b/src/lib/seed/pages.ts
index c605e0a1..c01b4fc2 100644
--- a/src/lib/seed/pages.ts
+++ b/src/lib/seed/pages.ts
@@ -1,5 +1,6 @@
import type { Payload } from 'payload'
+import { SHADER_PRESETS } from '@/components/HeroMedia/shaderPresets'
import type { Random } from '@/lib/seed/lexical'
import {
chance,
@@ -300,13 +301,34 @@ export const seedPages = async (
continue
}
- onProgress?.({
- step: `Creating hero image for ${slug}`,
- current: index,
- total: count,
- })
+ const random = createRandom(slug)
+ const useShader = chance(random, 0.25)
- const imageId = await createSeedImage(payload, index)
+ let background: Page['hero']['background']
+ if (useShader) {
+ background = {
+ backgroundType: 'shader',
+ shader: pick(random, SHADER_PRESETS).key,
+ }
+ } else {
+ onProgress?.({
+ step: `Creating hero image for ${slug}`,
+ current: index,
+ total: count,
+ })
+
+ const imageId = await createSeedImage(payload, index)
+
+ background = {
+ backgroundType: 'media',
+ media: [
+ {
+ relationTo: 'images',
+ value: imageId,
+ },
+ ],
+ }
+ }
onProgress?.({
step: `Creating ${slug}`,
@@ -329,12 +351,7 @@ export const seedPages = async (
],
hero: {
contentType: 'title',
- media: [
- {
- relationTo: 'images',
- value: imageId,
- },
- ],
+ background,
},
content: pageBlocks(slug),
},
@@ -381,7 +398,7 @@ export const cleanPages = async (
const mediaIds = new Set()
for (const page of pages) {
- for (const entry of page.hero?.media ?? []) {
+ for (const entry of page.hero?.background?.media ?? []) {
if (entry.relationTo === 'images') {
mediaIds.add(typeof entry.value === 'string' ? entry.value : String(entry.value.id))
}
diff --git a/src/lib/seed/posts.test.ts b/src/lib/seed/posts.test.ts
index b3831bf8..f8a96e9c 100644
--- a/src/lib/seed/posts.test.ts
+++ b/src/lib/seed/posts.test.ts
@@ -27,10 +27,11 @@ beforeEach(() => {
})
describe('seedPosts', () => {
- it('creates the requested number of posts, each tagged and with a hero image', async () => {
+ it('creates the requested number of posts, each tagged and with a hero background', async () => {
find.mockResolvedValue({
docs: [],
})
+ const heroes: unknown[] = []
create.mockImplementation(async ({ collection, data }) => {
if (collection === 'images') {
return {
@@ -45,10 +46,7 @@ describe('seedPosts', () => {
expect(data.generatorFlags).toEqual([
'seeded-dummy',
])
- expect(data.heroImage).toEqual({
- relationTo: 'images',
- value: 'image-1',
- })
+ heroes.push(data.hero)
return {
id: 'post-1',
}
@@ -57,6 +55,101 @@ describe('seedPosts', () => {
const result = await seedPosts(makePayload(), 2)
expect(result.created).toBe(2)
+ for (const hero of heroes) {
+ const background = (
+ hero as {
+ background: {
+ backgroundType: string
+ }
+ }
+ ).background
+ expect([
+ 'media',
+ 'shader',
+ ]).toContain(background.backgroundType)
+ if (background.backgroundType === 'media') {
+ expect(background).toEqual({
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ })
+ } else {
+ expect(background).toEqual({
+ backgroundType: 'shader',
+ shader: expect.any(String),
+ })
+ }
+ }
+ })
+
+ it('gives a shader-backed hero the correct background shape and skips image creation for it, while lexicalArticle still receives an empty imageIds', async () => {
+ find.mockResolvedValue({
+ docs: [],
+ })
+ const heroBackgrounds: unknown[] = []
+ create.mockImplementation(async ({ collection, data }) => {
+ if (collection === 'images') {
+ return {
+ id: 'image-1',
+ }
+ }
+ if (collection === 'topics') {
+ return {
+ id: 'topic-1',
+ }
+ }
+ heroBackgrounds.push(data.hero.background)
+ return {
+ id: 'post-1',
+ }
+ })
+
+ // A larger batch makes it overwhelmingly likely (with chance(random,
+ // 0.25) seeded per-slug) that at least one post lands on the shader
+ // branch, without mocking the randomness source directly.
+ await seedPosts(makePayload(), 20)
+
+ const shaderBackgrounds = heroBackgrounds.filter(
+ (background) =>
+ (
+ background as {
+ backgroundType: string
+ }
+ ).backgroundType === 'shader',
+ )
+ const mediaBackgrounds = heroBackgrounds.filter(
+ (background) =>
+ (
+ background as {
+ backgroundType: string
+ }
+ ).backgroundType === 'media',
+ )
+
+ expect(shaderBackgrounds.length).toBeGreaterThan(0)
+ expect(mediaBackgrounds.length).toBeGreaterThan(0)
+
+ for (const background of shaderBackgrounds) {
+ expect(background).toEqual({
+ backgroundType: 'shader',
+ shader: expect.any(String),
+ })
+ }
+ for (const background of mediaBackgrounds) {
+ expect(background).toEqual({
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ })
+ }
+
+ // Image creation is skipped entirely for shader-backed posts.
+ const imageCreates = create.mock.calls.filter(([args]) => args.collection === 'images')
+ expect(imageCreates).toHaveLength(mediaBackgrounds.length)
})
it('skips a slug that already exists instead of creating a duplicate', async () => {
@@ -326,9 +419,14 @@ describe('cleanPosts', () => {
docs: [
{
id: 'post-1',
- heroImage: {
- relationTo: 'images',
- value: 'image-1',
+ hero: {
+ background: {
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ },
},
},
],
@@ -376,9 +474,14 @@ describe('cleanPosts', () => {
docs: [
{
id: 'post-1',
- heroImage: {
- relationTo: 'images',
- value: 'image-1',
+ hero: {
+ background: {
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: 'image-1',
+ },
+ },
},
},
],
@@ -407,7 +510,7 @@ describe('cleanPosts', () => {
docs: [
{
id: 'post-1',
- heroImage: null,
+ hero: null,
},
],
}
diff --git a/src/lib/seed/posts.ts b/src/lib/seed/posts.ts
index 9cb54a51..d426faa2 100644
--- a/src/lib/seed/posts.ts
+++ b/src/lib/seed/posts.ts
@@ -1,5 +1,6 @@
import type { Payload } from 'payload'
+import { SHADER_PRESETS } from '@/components/HeroMedia/shaderPresets'
import { generateSlug } from '@/lib/generateSlug'
import {
chance,
@@ -444,13 +445,33 @@ export const seedPosts = async (
continue
}
- onProgress?.({
- step: `Creating hero image for ${slug}`,
- current: index,
- total: count,
- })
-
- const imageId = await createSeedImage(payload, index)
+ const random = createRandom(slug)
+ const useShader = chance(random, 0.25)
+
+ let imageId: string | undefined
+ let background: BlogPostData['hero']['background']
+ if (useShader) {
+ background = {
+ backgroundType: 'shader',
+ shader: pick(random, SHADER_PRESETS).key,
+ }
+ } else {
+ onProgress?.({
+ step: `Creating hero image for ${slug}`,
+ current: index,
+ total: count,
+ })
+
+ imageId = await createSeedImage(payload, index)
+
+ background = {
+ backgroundType: 'media',
+ media: {
+ relationTo: 'images',
+ value: imageId,
+ },
+ }
+ }
const topicCount = Math.min(1 + (index % 3 === 0 ? 1 : 0), availableTopicIds.length)
const offset = availableTopicIds.length > 0 ? index % availableTopicIds.length : 0
@@ -475,9 +496,8 @@ export const seedPosts = async (
data: {
title,
slug,
- heroImage: {
- relationTo: 'images',
- value: imageId,
+ hero: {
+ background,
},
topics: topicIds.map((id) => ({
relationTo: 'topics' as const,
@@ -485,9 +505,11 @@ export const seedPosts = async (
})),
content: lexicalArticle({
title,
- imageIds: [
- imageId,
- ],
+ imageIds: imageId
+ ? [
+ imageId,
+ ]
+ : [],
}),
generatorFlags: [
'seeded-dummy',
@@ -534,8 +556,9 @@ export const cleanPosts = async (
const mediaIds = new Set()
for (const post of posts) {
- if (post.heroImage?.relationTo === 'images') {
- const { value } = post.heroImage
+ const media = post.hero?.background?.media
+ if (media && typeof media === 'object' && media.relationTo === 'images') {
+ const { value } = media
mediaIds.add(typeof value === 'string' ? value : String(value.id))
}
}
diff --git a/src/types/payload.ts b/src/types/payload.ts
index a3ff080b..50a2c828 100644
--- a/src/types/payload.ts
+++ b/src/types/payload.ts
@@ -283,21 +283,25 @@ export interface Page {
slug: string;
layout?: PageLayout;
hero?: {
- /**
- * Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own.
- */
- media?:
- | (
- | {
- relationTo: 'images';
- value: string | MediaImage;
- }
- | {
- relationTo: 'videos';
- value: string | MediaVideo;
- }
- )[]
- | null;
+ background?: {
+ backgroundType?: ('media' | 'shader') | null;
+ /**
+ * Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own.
+ */
+ media?:
+ | (
+ | {
+ relationTo: 'images';
+ value: string | MediaImage;
+ }
+ | {
+ relationTo: 'videos';
+ value: string | MediaVideo;
+ }
+ )[]
+ | null;
+ shader?: ('darkveil' | 'faulty-terminal' | 'gradient-blinds' | 'grainient') | null;
+ };
contentType?: ('title' | 'custom') | null;
content?: {
root: {
@@ -744,10 +748,24 @@ export interface BlogPostData {
id: string;
title: string;
slug: string;
- heroImage?: {
- relationTo: 'images';
- value: string | MediaImage;
- } | null;
+ hero?: {
+ background?: {
+ backgroundType?: ('media' | 'shader') | null;
+ /**
+ * Fills the first screen.
+ */
+ media?:
+ | ({
+ relationTo: 'images';
+ value: string | MediaImage;
+ } | null)
+ | ({
+ relationTo: 'videos';
+ value: string | MediaVideo;
+ } | null);
+ shader?: ('darkveil' | 'faulty-terminal' | 'gradient-blinds' | 'grainient') | null;
+ };
+ };
topics?:
| {
relationTo: 'topics';
@@ -1675,7 +1693,17 @@ export interface PayloadMigration {
export interface PostsSelect {
title?: T;
slug?: T;
- heroImage?: T;
+ hero?:
+ | T
+ | {
+ background?:
+ | T
+ | {
+ backgroundType?: T;
+ media?: T;
+ shader?: T;
+ };
+ };
topics?: T;
relatedPosts?: T;
content?: T;
@@ -1726,7 +1754,13 @@ export interface PagesSelect {
hero?:
| T
| {
- media?: T;
+ background?:
+ | T
+ | {
+ backgroundType?: T;
+ media?: T;
+ shader?: T;
+ };
contentType?: T;
content?: T;
};