Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ff5e763
feat(hero): add HeroBackgroundField and shader preset registry
danielheene Aug 21, 2026
b1019c9
feat(hero): add the shader picker drawer admin field component
danielheene Aug 21, 2026
9799074
fix(hero): disable the shader picker when the field is read-only
danielheene Aug 21, 2026
30c0ca0
feat(hero): add shader-aware rendering to HeroMedia
danielheene Aug 21, 2026
68a3ed6
fix(hero): update not-found.tsx's HeroMedia call to the new backgroun…
danielheene Aug 21, 2026
66adbc4
feat(hero): migrate Pages' hero.media to hero.background
danielheene Aug 21, 2026
5fcc48c
feat(hero): migrate BlogPosts' heroImage to hero.background
danielheene Aug 21, 2026
0ba8a9f
fix(hero): correct BlogPosts' listViewThumbnailPath for its single-ob…
danielheene Aug 21, 2026
7112122
fix(hero): correct Pages' listViewThumbnailPath for its polymorphic m…
danielheene Aug 21, 2026
32474da
fix(og): render the root OG image with real site data via Takumi
danielheene Aug 21, 2026
c2c01a1
feat(og): add a per-page OG image for Pages
danielheene Aug 21, 2026
f2f6a2d
feat(og): add a per-post OG image for BlogPosts
danielheene Aug 21, 2026
a0644ab
feat(og): add an OG image for BlogTopics
danielheene Aug 21, 2026
f4fb97f
feat(og): add OG images for the /blog index and its pagination
danielheene Aug 21, 2026
abdb1c2
feat(og): add generateMetadata and an OG image for ResumeDocuments
danielheene Aug 21, 2026
bd7eb71
feat(seed): occasionally give seeded Pages/BlogPosts a shader hero
danielheene Aug 21, 2026
ffc1b99
chore: fix verification findings
danielheene Aug 21, 2026
ac7f308
fix: complete generateMeta's openGraph block, guard the shader-preset…
danielheene Aug 21, 2026
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
92 changes: 92 additions & 0 deletions app/(frontend)/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { notFound } from 'next/navigation'

import { ImageResponse } from 'takumi-js/response'

import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets'

import { queryPageBySlug } from './page'

export const alt = 'Page'
export const size = {
width: 1200,
height: 630,
}

type Props = {
params: Promise<{
slug?: string
}>
}

export default async function Image({ params }: Props) {
const { slug = 'home' } = await params
const page = await queryPageBySlug(slug)
if (!page) notFound()

const background = page.hero?.background
const title = page.title ?? 'Page'

const backgroundStyle: React.CSSProperties =
background?.backgroundType === 'shader' && background.shader
? {
background:
SHADER_PRESET_MAP[background.shader as keyof typeof SHADER_PRESET_MAP]?.gradient,
}
: {}

// Pages' `hero.background.media` is a `hasMany: true` polymorphic upload
// field: it always resolves as an array of `{ relationTo, value }` wrapper
// objects, never a bare media object. `value` only holds the populated
// media document (with `.url`) once the relation has been resolved by the
// query this route reuses (`queryPageBySlug`, shared with `[slug]/page.tsx`).
// Mirrors the `isPopulated`/`toItems` pattern in `HeroMedia.tsx`.
const firstImageEntry = Array.isArray(background?.media)
? background.media.find(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
entry.relationTo === 'images' &&
typeof entry.value === 'object' &&
entry.value !== null,
)
: undefined

const heroImageUrl =
background?.backgroundType === 'media' &&
firstImageEntry &&
typeof firstImageEntry.value === 'object'
? (firstImageEntry.value?.url ?? undefined)
: undefined

return new ImageResponse(
<div
tw="relative flex h-full w-full flex-col items-start justify-end px-16 pb-20 text-neutral-100"
style={backgroundStyle}
>
{heroImageUrl && (
// biome-ignore lint/performance/noImgElement: Takumi/OG image rendering requires a plain <img>, not next/image
<img
alt=""
src={heroImageUrl}
tw="absolute inset-0 h-full w-full object-cover"
style={{
position: 'absolute',
zIndex: -1,
}}
/>
)}
<div
tw="absolute inset-0 bg-black/40"
style={{
position: 'absolute',
zIndex: -1,
}}
/>
<h1 tw="max-w-4xl text-6xl font-bold leading-[1.15] tracking-tight">{title}</h1>
</div>,
{
width: 1200,
height: 630,
},
)
}
4 changes: 2 additions & 2 deletions app/(frontend)/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default async function Page({ params }: PageProps) {
<HeroMedia
className="border-b-2 border-b-primary"
fallbackAlt={title || 'Hero Image'}
media={hero?.media}
background={hero?.background}
>
{title && (
<div className="pt-40 pb-20">
Expand Down Expand Up @@ -147,7 +147,7 @@ const queryDraftPageBySlug = async (slug: string) => {
return (docs[0] as CollectionData<CollectionSlug['Pages']>) || null
}

const queryPageBySlug = cache(async (slug: string) => {
export const queryPageBySlug = cache(async (slug: string) => {
const { isEnabled: draft } = await draftMode()
return draft ? queryDraftPageBySlug(slug) : queryPublishedPageBySlug(slug)
})
38 changes: 38 additions & 0 deletions app/(frontend)/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { notFound } from 'next/navigation'

import { ImageResponse } from 'takumi-js/response'

import { RESERVED_TOPIC_SLUGS } from '@/types/blog'

import { queryPublishedTopicBySlug } from '../_shared/BlogListPage'

export const alt = 'Blog topic'
export const size = {
width: 1200,
height: 630,
}

type Props = {
params: Promise<{
slug: string
}>
}

export default async function Image({ params }: Props) {
const { slug } = await params
if (RESERVED_TOPIC_SLUGS.includes(slug)) notFound()

const topic = await queryPublishedTopicBySlug(slug)
if (!topic) notFound()

return new ImageResponse(
<div tw="flex h-full w-full flex-col items-start justify-center bg-neutral-900 px-16 text-neutral-100">
<p tw="text-2xl font-medium text-neutral-400">Blog</p>
<h1 tw="mt-4 max-w-4xl text-6xl font-bold leading-[1.15] tracking-tight">{topic.title}</h1>
</div>,
{
width: 1200,
height: 630,
},
)
}
26 changes: 25 additions & 1 deletion app/(frontend)/blog/_shared/BlogListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getPayload } from 'payload'

import { format } from 'date-fns'

import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets'
import { ImageMedia } from '@/components/ImageMedia'
import { PageContainer } from '@/components/PageContainer'
import { cn } from '@/lib/cn'
Expand Down Expand Up @@ -86,8 +87,23 @@ const queryPublishedPosts = async ({ topicId, page }: { topicId?: string; page:
const PostCard = ({ post }: { post: BlogPostData }) => {
// populated upload values omit mimeType, so isMediaImage() can't be used —
// the relation's target collection is the reliable discriminator
const background = post.hero?.background
const heroImage =
post.heroImage?.relationTo === CollectionSlug.MediaImages ? post.heroImage.value : undefined
background?.backgroundType === 'media' &&
typeof background.media === 'object' &&
background.media !== null &&
'relationTo' in background.media &&
background.media.relationTo === CollectionSlug.MediaImages
? background.media.value
: undefined
// guarded like the OG image routes: `shader` is typed as the preset-key
// union but the value comes from the database, so a preset that has since
// been renamed or removed resolves to undefined — degrade to no background
// div rather than throwing and taking down the whole server-rendered list
const shaderGradient =
background?.backgroundType === 'shader' && typeof background.shader === 'string'
? SHADER_PRESET_MAP[background.shader]?.gradient
: undefined

return (
<Link
Expand Down Expand Up @@ -128,6 +144,14 @@ const PostCard = ({ post }: { post: BlogPostData }) => {
])}
/>
)}
{shaderGradient && (
<div
className="absolute inset-0 -z-10 size-full rounded-lg brightness-50"
style={{
background: shaderGradient,
}}
/>
)}
</Link>
)
}
Expand Down
19 changes: 19 additions & 0 deletions app/(frontend)/blog/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ImageResponse } from 'takumi-js/response'

export const alt = 'Blog'
export const size = {
width: 1200,
height: 630,
}

export default async function Image() {
return new ImageResponse(
<div tw="flex h-full w-full items-center justify-center bg-neutral-900 text-neutral-100">
<h1 tw="text-7xl font-bold tracking-tight">Blog</h1>
</div>,
{
width: 1200,
height: 630,
},
)
}
28 changes: 28 additions & 0 deletions app/(frontend)/blog/page/[page]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ImageResponse } from 'takumi-js/response'

export const alt = 'Blog'
export const size = {
width: 1200,
height: 630,
}

type Props = {
params: Promise<{
page: string
}>
}

export default async function Image({ params }: Props) {
const { page } = await params

return new ImageResponse(
<div tw="flex h-full w-full flex-col items-center justify-center bg-neutral-900 text-neutral-100">
<h1 tw="text-7xl font-bold tracking-tight">Blog</h1>
<p tw="mt-4 text-2xl text-neutral-400">Page {page}</p>
</div>,
{
width: 1200,
height: 630,
},
)
}
85 changes: 85 additions & 0 deletions app/(frontend)/blog/post/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { notFound } from 'next/navigation'

import { ImageResponse } from 'takumi-js/response'

import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets'

import { queryPostBySlug } from './page'

export const alt = 'Blog post'
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 post = await queryPostBySlug({
slug: slug ?? '',
})
if (!post) notFound()

const background = post.hero?.background
const title = post.title ?? 'Blog post'

const backgroundStyle: React.CSSProperties =
background?.backgroundType === 'shader' && background.shader
? {
background:
SHADER_PRESET_MAP[background.shader as keyof typeof SHADER_PRESET_MAP]?.gradient,
}
: {}

// BlogPosts' `hero.background.media` is a `hasMany: false` polymorphic
// upload field: it resolves as a single `{ relationTo, value }` wrapper
// object (never an array), where `value` holds the populated media
// document (with `.url`) once resolved by `queryPostBySlug`. Mirrors the
// `isPopulated` discriminator in `HeroMedia.tsx`, minus the array step.
const heroImageUrl =
background?.backgroundType === 'media' &&
background.media &&
typeof background.media === 'object' &&
'relationTo' in background.media &&
background.media.relationTo === 'images' &&
typeof background.media.value === 'object'
? (background.media.value?.url ?? undefined)
: undefined

return new ImageResponse(
<div
tw="relative flex h-full w-full flex-col items-start justify-end px-16 pb-20 text-neutral-100"
style={backgroundStyle}
>
{heroImageUrl && (
// biome-ignore lint/performance/noImgElement: Takumi/OG image rendering requires a plain <img>, not next/image
<img
alt=""
src={heroImageUrl}
tw="absolute inset-0 h-full w-full object-cover"
style={{
position: 'absolute',
zIndex: -1,
}}
/>
)}
<div
tw="absolute inset-0 bg-black/40"
style={{
position: 'absolute',
zIndex: -1,
}}
/>
<h1 tw="max-w-4xl text-6xl font-bold leading-[1.15] tracking-tight">{title}</h1>
</div>,
{
width: 1200,
height: 630,
},
)
}
23 changes: 11 additions & 12 deletions app/(frontend)/blog/post/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { resolveRelations } from '@/lib/resolveRelation'
import { highlightRichText } from '@/lib/shiki/highlightRichText'
import { CollectionData, CollectionSlug } from '@/types/collections'

import { size } from './opengraph-image'

export async function generateStaticParams() {
const payload = await getPayload({
config,
Expand Down Expand Up @@ -62,11 +64,10 @@ export default async function Page({ params: paramsPromise }: PageProps) {
})
if (!post) return notFound()

const { title, content, heroImage, createdAt, updatedAt, topics } = post
const { title, content, hero, createdAt, updatedAt, topics } = post
const baseUrl = process.env.SERVER_URL || 'https://danielheene.de'
const postUrl = `${baseUrl}/posts/${slug}`

const heroData = heroImage?.value
const headings = extractHeadings(content)
// Shiki is server-only, so code blocks are highlighted here and handed to
// RichText, which renders on the client
Expand All @@ -78,14 +79,12 @@ export default async function Page({ params: paramsPromise }: PageProps) {
.filter((value) => typeof value === 'object' && value !== null)
const primaryTopic = topicList[0]

const heroImageData = heroData?.url
? {
url: new URL(new URL(heroData.url).pathname, baseUrl).toString(),
width: heroData.width || undefined,
height: heroData.height || undefined,
alt: heroData.alt || title,
}
: undefined
const heroImageData = {
url: `${baseUrl}/blog/post/${slug}/opengraph-image`,
width: size.width,
height: size.height,
alt: title,
}
//
// // Extract keywords from tags
// const postTags = tags
Expand Down Expand Up @@ -136,7 +135,7 @@ export default async function Page({ params: paramsPromise }: PageProps) {
<div className="flex flex-1 items-center justify-center">
<section className="pb-32 w-full">
{/* A post carries at most one hero image, so this never becomes a carousel. */}
<HeroMedia fallbackAlt={title} media={heroImage}>
<HeroMedia fallbackAlt={title} background={hero?.background}>
<div className="pt-40 pb-16">
<div className="container flex flex-col items-center gap-8 text-center">
<nav aria-label="breadcrumb">
Expand Down Expand Up @@ -286,7 +285,7 @@ const queryDraftPostBySlug = async (
return reduceDataToBilingualLanguage(await resolveRelations(docs[0]))
}

const queryPostBySlug = cache(
export const queryPostBySlug = cache(
async ({ slug }: { slug: string }): Promise<CollectionData<CollectionSlug['BlogPosts']>> => {
const { isEnabled: draft } = await draftMode()
return draft ? queryDraftPostBySlug(slug) : queryPublishedPostBySlug(slug)
Expand Down
Loading
Loading