feat: manage blog detail chrome via payload

This commit is contained in:
syntaxbullet
2026-06-22 11:54:18 +02:00
parent 43f85dfea2
commit aa839fcebc
7 changed files with 260 additions and 19 deletions

View File

@@ -17,6 +17,7 @@
"migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts", "migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts",
"migrate:spa-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts", "migrate:spa-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"preload:blog-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-blog-detail-cms.ts",
"preload:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts", "preload:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts",
"preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts", "preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts",
"preload:datenschutz-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-datenschutz-page-cms.ts", "preload:datenschutz-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-datenschutz-page-cms.ts",

View File

@@ -14,6 +14,7 @@ import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { Banner } from '../../blocks/Banner/config' import { Banner } from '../../blocks/Banner/config'
import { Code } from '../../blocks/Code/config' import { Code } from '../../blocks/Code/config'
import { MediaBlock } from '../../blocks/MediaBlock/config' import { MediaBlock } from '../../blocks/MediaBlock/config'
import { blogDetailContent } from '@/spa/blogDetailContent'
import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { generatePreviewPath } from '../../utilities/generatePreviewPath'
import { populateAuthors } from './hooks/populateAuthors' import { populateAuthors } from './hooks/populateAuthors'
import { revalidateDelete, revalidatePost } from './hooks/revalidatePost' import { revalidateDelete, revalidatePost } from './hooks/revalidatePost'
@@ -128,6 +129,68 @@ export const Posts: CollectionConfig<'posts'> = {
type: 'array', type: 'array',
fields: [{ name: 'slug', type: 'text', required: true }], fields: [{ name: 'slug', type: 'text', required: true }],
}, },
{
name: 'detailPage',
label: 'Detail page chrome',
type: 'group',
admin: {
description:
'Shared labels, category colors, meta copy, sidebar CTA, and related-article labels for the Presse detail route.',
},
fields: [
{
name: 'notFound',
label: 'Not found state',
type: 'group',
fields: [
{ name: 'title', type: 'text', defaultValue: blogDetailContent.notFound.title },
{ name: 'backLabel', type: 'text', defaultValue: blogDetailContent.notFound.backLabel },
{ name: 'backUrl', type: 'text', defaultValue: blogDetailContent.notFound.backUrl },
],
},
{
name: 'heroBackLink',
label: 'Hero back link',
type: 'group',
fields: [
{ name: 'label', type: 'text', defaultValue: blogDetailContent.heroBackLink.label },
{ name: 'url', type: 'text', defaultValue: blogDetailContent.heroBackLink.url },
],
},
{
name: 'categoryColors',
label: 'Category colors JSON',
type: 'json',
defaultValue: blogDetailContent.categoryColors,
},
{
name: 'metaCopy',
label: 'Meta copy',
type: 'group',
fields: [{ name: 'readTimeSuffix', type: 'text', defaultValue: blogDetailContent.meta.readTimeSuffix }],
},
{
name: 'sidebarCta',
label: 'Sidebar CTA',
type: 'group',
fields: [
{ name: 'heading', type: 'text', defaultValue: blogDetailContent.sidebarCta.heading },
{ name: 'text', type: 'textarea', defaultValue: blogDetailContent.sidebarCta.text },
{ name: 'label', type: 'text', defaultValue: blogDetailContent.sidebarCta.label },
{ name: 'url', type: 'text', defaultValue: blogDetailContent.sidebarCta.url },
],
},
{
name: 'related',
label: 'Related articles',
type: 'group',
fields: [
{ name: 'heading', type: 'text', defaultValue: blogDetailContent.related.heading },
{ name: 'readMoreLabel', type: 'text', defaultValue: blogDetailContent.related.readMoreLabel },
],
},
],
},
{ {
name: 'content', name: 'content',
type: 'richText', type: 'richText',

View File

@@ -1879,6 +1879,42 @@ export interface Post {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
/**
* Shared labels, category colors, meta copy, sidebar CTA, and related-article labels for the Presse detail route.
*/
detailPage?: {
notFound?: {
title?: string | null;
backLabel?: string | null;
backUrl?: string | null;
};
heroBackLink?: {
label?: string | null;
url?: string | null;
};
categoryColors?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
metaCopy?: {
readTimeSuffix?: string | null;
};
sidebarCta?: {
heading?: string | null;
text?: string | null;
label?: string | null;
url?: string | null;
};
related?: {
heading?: string | null;
readMoreLabel?: string | null;
};
};
content: { content: {
root: { root: {
type: string; type: string;
@@ -4465,6 +4501,43 @@ export interface PostsSelect<T extends boolean = true> {
slug?: T; slug?: T;
id?: T; id?: T;
}; };
detailPage?:
| T
| {
notFound?:
| T
| {
title?: T;
backLabel?: T;
backUrl?: T;
};
heroBackLink?:
| T
| {
label?: T;
url?: T;
};
categoryColors?: T;
metaCopy?:
| T
| {
readTimeSuffix?: T;
};
sidebarCta?:
| T
| {
heading?: T;
text?: T;
label?: T;
url?: T;
};
related?:
| T
| {
heading?: T;
readMoreLabel?: T;
};
};
content?: T; content?: T;
relatedPosts?: T; relatedPosts?: T;
categories?: T; categories?: T;

View File

@@ -5,6 +5,7 @@ import 'dotenv/config'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { articles } from '@/spa/data/blog' import { articles } from '@/spa/data/blog'
import { blogDetailContent } from '@/spa/blogDetailContent'
import { eventDetailContent } from '@/spa/eventDetailContent' import { eventDetailContent } from '@/spa/eventDetailContent'
import { events } from '@/spa/data/events' import { events } from '@/spa/data/events'
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
@@ -130,6 +131,10 @@ function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
sections: article?.sections || [], sections: article?.sections || [],
tags: article?.tags?.map((tag) => ({ tag })) || [], tags: article?.tags?.map((tag) => ({ tag })) || [],
relatedSlugs: article?.relatedSlugs?.map((slug) => ({ slug })) || [], relatedSlugs: article?.relatedSlugs?.map((slug) => ({ slug })) || [],
detailPage: {
...blogDetailContent,
metaCopy: blogDetailContent.meta,
},
content: emptyRichText( content: emptyRichText(
article?.sections?.map((section) => [section.heading, section.body].filter(Boolean).join('\n')).join('\n\n') || article?.sections?.map((section) => [section.heading, section.body].filter(Boolean).join('\n')).join('\n\n') ||
`Dieser Presseartikel wird aktuell noch von der migrierten SPA gerendert. Über „Preview“ kann der aktuelle Stand unter ${page.path} betrachtet werden.`, `Dieser Presseartikel wird aktuell noch von der migrierten SPA gerendert. Über „Preview“ kann der aktuelle Stand unter ${page.path} betrachtet werden.`,

View File

@@ -0,0 +1,42 @@
import 'dotenv/config'
import config from '@payload-config'
import { getPayload } from 'payload'
import { blogDetailContent } from '@/spa/blogDetailContent'
async function main() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
draft: true,
overrideAccess: true,
pagination: false,
limit: 1000,
})
await Promise.all(
posts.docs.map((post) =>
payload.update({
collection: 'posts',
id: post.id,
overrideAccess: true,
context: { disableRevalidate: true },
data: {
detailPage: {
...blogDetailContent,
metaCopy: blogDetailContent.meta,
},
} as never,
}),
),
)
payload.logger.info(`Preloaded Blog detail CMS fields for ${posts.docs.length} records`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -0,0 +1,29 @@
export const blogDetailContent = {
notFound: {
title: 'Artikel nicht gefunden',
backLabel: 'Zurück zur Presse',
backUrl: '/presse',
},
heroBackLink: {
label: 'Newsroom',
url: '/presse',
},
categoryColors: {
Innovation: '#2563EB',
Engagement: '#16A34A',
Wirtschaft: '#9333EA',
},
meta: {
readTimeSuffix: 'Lesezeit',
},
sidebarCta: {
heading: 'Jetzt bewerben',
text: 'Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026?',
label: 'Zur Bewerbung',
url: '/teilnahme',
},
related: {
heading: 'Weitere Artikel',
readMoreLabel: 'Weiterlesen',
},
}

View File

@@ -6,6 +6,7 @@ import { getArticleBySlug, articles, type BlogArticle, type BlogSection } from '
import { useCmsRoute } from '@/spa/cmsRoute'; import { useCmsRoute } from '@/spa/cmsRoute';
import { docImageUrl } from '@/spa/cmsMediaField'; import { docImageUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { blogDetailContent } from '@/spa/blogDetailContent';
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55'; const NAVY = '#111D55';
@@ -15,10 +16,29 @@ const INK = '#3A3A3A';
const FF = '"IBM Plex Sans", sans-serif'; const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif'; const FB = '"Inter", sans-serif';
const CAT_COLORS: Record<string, string> = { type BlogDetailCms = Omit<Partial<typeof blogDetailContent>, 'categoryColors'> & {
Innovation: '#2563EB', categoryColors?: unknown
Engagement: '#16A34A', metaCopy?: Partial<typeof blogDetailContent.meta>
Wirtschaft: '#9333EA', }
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const colorMap = (value: unknown): Record<string, string> => {
if (!value || typeof value !== 'object') return blogDetailContent.categoryColors;
return { ...blogDetailContent.categoryColors, ...(value as Record<string, string>) };
};
const getDetailPage = (value: unknown): BlogDetailCms => {
const detail = (value && typeof value === 'object' ? value : {}) as BlogDetailCms;
return {
...blogDetailContent,
...detail,
notFound: { ...blogDetailContent.notFound, ...detail.notFound },
heroBackLink: { ...blogDetailContent.heroBackLink, ...detail.heroBackLink },
categoryColors: colorMap(detail.categoryColors),
metaCopy: { ...blogDetailContent.meta, ...(detail.metaCopy || detail.meta) },
sidebarCta: { ...blogDetailContent.sidebarCta, ...detail.sidebarCta },
related: { ...blogDetailContent.related, ...detail.related },
};
}; };
export default function BlogDetail() { export default function BlogDetail() {
@@ -47,14 +67,22 @@ export default function BlogDetail() {
.map((item: { slug?: string }) => item.slug) .map((item: { slug?: string }) => item.slug)
.filter((slug): slug is string => Boolean(slug)) .filter((slug): slug is string => Boolean(slug))
: [], : [],
detailPage: cmsArticle.detailPage,
} as BlogArticle : undefined); } as BlogArticle : undefined);
const detail = getDetailPage(article && 'detailPage' in article ? article.detailPage : undefined);
const notFound = detail.notFound || blogDetailContent.notFound;
const heroBackLink = detail.heroBackLink || blogDetailContent.heroBackLink;
const categoryColors = colorMap(detail.categoryColors);
const metaCopy = detail.metaCopy || blogDetailContent.meta;
const sidebarCta = detail.sidebarCta || blogDetailContent.sidebarCta;
const relatedCopy = detail.related || blogDetailContent.related;
if (!article) { if (!article) {
return ( return (
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: CREAM }}> <div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: CREAM }}>
<h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>Artikel nicht gefunden</h2> <h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
<Link to="/presse" style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}> <Link to={fallbackText(notFound.backUrl, blogDetailContent.notFound.backUrl)} style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
<ArrowLeft size={14} /> Zurück zur Presse <ArrowLeft size={14} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
</Link> </Link>
</div> </div>
); );
@@ -82,7 +110,7 @@ export default function BlogDetail() {
<div style={{ position: 'relative', zIndex: 2, padding: isMobile ? '0 24px 48px' : '0 80px 72px', maxWidth: 860 }}> <div style={{ position: 'relative', zIndex: 2, padding: isMobile ? '0 24px 48px' : '0 80px 72px', maxWidth: 860 }}>
{/* Back link */} {/* Back link */}
<Link <Link
to="/presse" to={fallbackText(heroBackLink.url, blogDetailContent.heroBackLink.url)}
style={{ style={{
display: 'inline-flex', alignItems: 'center', gap: 8, display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: FF, fontSize: 11, fontWeight: 700, fontFamily: FF, fontSize: 11, fontWeight: 700,
@@ -93,7 +121,7 @@ export default function BlogDetail() {
onMouseEnter={e => (e.currentTarget.style.color = '#fff')} onMouseEnter={e => (e.currentTarget.style.color = '#fff')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.5)')} onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.5)')}
> >
<ArrowLeft size={13} /> Newsroom <ArrowLeft size={13} /> {fallbackText(heroBackLink.label, blogDetailContent.heroBackLink.label)}
</Link> </Link>
{/* Category badge */} {/* Category badge */}
@@ -103,7 +131,7 @@ export default function BlogDetail() {
padding: '4px 12px', padding: '4px 12px',
fontFamily: FF, fontSize: 9, fontWeight: 700, fontFamily: FF, fontSize: 9, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.22em', textTransform: 'uppercase', letterSpacing: '0.22em',
background: CAT_COLORS[article.cat] ?? GOLD, background: categoryColors[article.cat] ?? GOLD,
color: '#fff', color: '#fff',
}}> }}>
{article.cat} {article.cat}
@@ -133,7 +161,7 @@ export default function BlogDetail() {
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 28, flexWrap: 'wrap' }}>
{[ {[
{ icon: User, label: `${article.author} · ${article.authorRole}` }, { icon: User, label: `${article.author} · ${article.authorRole}` },
{ icon: Clock, label: `${article.date} · ${article.readTime} Lesezeit` }, { icon: Clock, label: `${article.date} · ${article.readTime} ${fallbackText(metaCopy.readTimeSuffix, blogDetailContent.meta.readTimeSuffix)}` },
].map(({ icon: Icon, label }, i) => ( ].map(({ icon: Icon, label }, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.5)' }}> <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.5)' }}>
<Icon size={13} color={GOLD} /> {label} <Icon size={13} color={GOLD} /> {label}
@@ -225,13 +253,13 @@ export default function BlogDetail() {
<div style={{ background: '#fff', border: '1px solid rgba(3,9,58,0.1)', padding: '28px' }}> <div style={{ background: '#fff', border: '1px solid rgba(3,9,58,0.1)', padding: '28px' }}>
<div style={{ width: 32, height: 2, background: GOLD, marginBottom: 16 }} /> <div style={{ width: 32, height: 2, background: GOLD, marginBottom: 16 }} />
<h4 style={{ fontFamily: FF, fontSize: 15, fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}> <h4 style={{ fontFamily: FF, fontSize: 15, fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}>
Jetzt bewerben {fallbackText(sidebarCta.heading, blogDetailContent.sidebarCta.heading)}
</h4> </h4>
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}> <p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}>
Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026? {fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
</p> </p>
<Link <Link
to="/teilnahme" to={fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url)}
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '13px 20px', padding: '13px 20px',
@@ -244,7 +272,7 @@ export default function BlogDetail() {
onMouseEnter={e => (e.currentTarget.style.background = GOLD)} onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)} onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
> >
Zur Bewerbung <ArrowRight size={12} /> {fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</Link> </Link>
</div> </div>
</div> </div>
@@ -259,14 +287,14 @@ export default function BlogDetail() {
<div style={{ display: 'flex', alignItems: 'baseline', gap: 20, marginBottom: 56 }}> <div style={{ display: 'flex', alignItems: 'baseline', gap: 20, marginBottom: 56 }}>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)' }}> <span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.28em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)' }}>
Weitere Artikel {fallbackText(relatedCopy.heading, blogDetailContent.related.heading)}
</span> </span>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.1)' }} /> <div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.1)' }} />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${related.length}, 1fr)`, gap: 0 }}> <div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${related.length}, 1fr)`, gap: 0 }}>
{related.map((rel, idx) => ( {related.map((rel, idx) => (
<RelatedCard key={rel.slug} article={rel} bordered={isMobile ? false : idx > 0} isMobile={isMobile} /> <RelatedCard key={rel.slug} article={rel} bordered={isMobile ? false : idx > 0} isMobile={isMobile} readMoreLabel={fallbackText(relatedCopy.readMoreLabel, blogDetailContent.related.readMoreLabel)} />
))} ))}
</div> </div>
</div> </div>
@@ -277,7 +305,7 @@ export default function BlogDetail() {
); );
} }
function RelatedCard({ article, bordered, isMobile }: { article: ReturnType<typeof getArticleBySlug> & object; bordered: boolean; isMobile: boolean }) { function RelatedCard({ article, bordered, isMobile, readMoreLabel }: { article: ReturnType<typeof getArticleBySlug> & object; bordered: boolean; isMobile: boolean; readMoreLabel: string }) {
const [hovered, setHovered] = React.useState(false); const [hovered, setHovered] = React.useState(false);
if (!article) return null; if (!article) return null;
return ( return (
@@ -319,7 +347,7 @@ function RelatedCard({ article, bordered, isMobile }: { article: ReturnType<type
{article.excerpt} {article.excerpt}
</p> </p>
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, display: 'inline-flex', alignItems: 'center', gap: 5 }}> <span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
Weiterlesen <ArrowRight size={12} /> {readMoreLabel} <ArrowRight size={12} />
</span> </span>
</Link> </Link>
); );