From aa839fcebc71e1e7c67e848c4c9adba13f7dfb36 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 22 Jun 2026 11:54:18 +0200 Subject: [PATCH] feat: manage blog detail chrome via payload --- package.json | 1 + src/collections/Posts/index.ts | 63 ++++++++++++++++++++++ src/payload-types.ts | 73 ++++++++++++++++++++++++++ src/scripts/migrate-spa-pages.ts | 5 ++ src/scripts/preload-blog-detail-cms.ts | 42 +++++++++++++++ src/spa/blogDetailContent.ts | 29 ++++++++++ src/spa/pages/BlogDetail.tsx | 66 ++++++++++++++++------- 7 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 src/scripts/preload-blog-detail-cms.ts create mode 100644 src/spa/blogDetailContent.ts diff --git a/package.json b/package.json index 501529c..54e8e3b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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", "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: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", diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index 5b9c65c..243a8b6 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -14,6 +14,7 @@ import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' import { Banner } from '../../blocks/Banner/config' import { Code } from '../../blocks/Code/config' import { MediaBlock } from '../../blocks/MediaBlock/config' +import { blogDetailContent } from '@/spa/blogDetailContent' import { generatePreviewPath } from '../../utilities/generatePreviewPath' import { populateAuthors } from './hooks/populateAuthors' import { revalidateDelete, revalidatePost } from './hooks/revalidatePost' @@ -128,6 +129,68 @@ export const Posts: CollectionConfig<'posts'> = { type: 'array', 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', type: 'richText', diff --git a/src/payload-types.ts b/src/payload-types.ts index e6cbd02..89de03b 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1879,6 +1879,42 @@ export interface Post { id?: string | 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: { root: { type: string; @@ -4465,6 +4501,43 @@ export interface PostsSelect { slug?: 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; relatedPosts?: T; categories?: T; diff --git a/src/scripts/migrate-spa-pages.ts b/src/scripts/migrate-spa-pages.ts index a4208c4..f2b4329 100644 --- a/src/scripts/migrate-spa-pages.ts +++ b/src/scripts/migrate-spa-pages.ts @@ -5,6 +5,7 @@ import 'dotenv/config' import { getPayload } from 'payload' import { articles } from '@/spa/data/blog' +import { blogDetailContent } from '@/spa/blogDetailContent' import { eventDetailContent } from '@/spa/eventDetailContent' import { events } from '@/spa/data/events' import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' @@ -130,6 +131,10 @@ function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { sections: article?.sections || [], tags: article?.tags?.map((tag) => ({ tag })) || [], relatedSlugs: article?.relatedSlugs?.map((slug) => ({ slug })) || [], + detailPage: { + ...blogDetailContent, + metaCopy: blogDetailContent.meta, + }, content: emptyRichText( 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.`, diff --git a/src/scripts/preload-blog-detail-cms.ts b/src/scripts/preload-blog-detail-cms.ts new file mode 100644 index 0000000..11fc748 --- /dev/null +++ b/src/scripts/preload-blog-detail-cms.ts @@ -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) +}) diff --git a/src/spa/blogDetailContent.ts b/src/spa/blogDetailContent.ts new file mode 100644 index 0000000..f9962ec --- /dev/null +++ b/src/spa/blogDetailContent.ts @@ -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', + }, +} diff --git a/src/spa/pages/BlogDetail.tsx b/src/spa/pages/BlogDetail.tsx index 0638a46..391ad24 100644 --- a/src/spa/pages/BlogDetail.tsx +++ b/src/spa/pages/BlogDetail.tsx @@ -6,6 +6,7 @@ import { getArticleBySlug, articles, type BlogArticle, type BlogSection } from ' import { useCmsRoute } from '@/spa/cmsRoute'; import { docImageUrl } from '@/spa/cmsMediaField'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { blogDetailContent } from '@/spa/blogDetailContent'; import Image from '@/spa/components/ui/UnoptimizedImage' const NAVY = '#111D55'; @@ -15,10 +16,29 @@ const INK = '#3A3A3A'; const FF = '"IBM Plex Sans", sans-serif'; const FB = '"Inter", sans-serif'; -const CAT_COLORS: Record = { - Innovation: '#2563EB', - Engagement: '#16A34A', - Wirtschaft: '#9333EA', +type BlogDetailCms = Omit, 'categoryColors'> & { + categoryColors?: unknown + metaCopy?: Partial +} + +const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback; +const colorMap = (value: unknown): Record => { + if (!value || typeof value !== 'object') return blogDetailContent.categoryColors; + return { ...blogDetailContent.categoryColors, ...(value as Record) }; +}; + +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() { @@ -47,14 +67,22 @@ export default function BlogDetail() { .map((item: { slug?: string }) => item.slug) .filter((slug): slug is string => Boolean(slug)) : [], + detailPage: cmsArticle.detailPage, } 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) { return (
-

Artikel nicht gefunden

- - Zurück zur Presse +

{fallbackText(notFound.title, blogDetailContent.notFound.title)}

+ + {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
); @@ -82,7 +110,7 @@ export default function BlogDetail() {
{/* Back link */} (e.currentTarget.style.color = '#fff')} onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.5)')} > - Newsroom + {fallbackText(heroBackLink.label, blogDetailContent.heroBackLink.label)} {/* Category badge */} @@ -103,7 +131,7 @@ export default function BlogDetail() { padding: '4px 12px', fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.22em', - background: CAT_COLORS[article.cat] ?? GOLD, + background: categoryColors[article.cat] ?? GOLD, color: '#fff', }}> {article.cat} @@ -133,7 +161,7 @@ export default function BlogDetail() {
{[ { 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) => (
{label} @@ -225,13 +253,13 @@ export default function BlogDetail() {

- Jetzt bewerben + {fallbackText(sidebarCta.heading, blogDetailContent.sidebarCta.heading)}

- Ist Ihr Unternehmen bereit für den Bayerischen Mittelstandspreis 2026? + {fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}

(e.currentTarget.style.background = GOLD)} onMouseLeave={e => (e.currentTarget.style.background = NAVY)} > - Zur Bewerbung + {fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)}
@@ -259,14 +287,14 @@ export default function BlogDetail() {
- Weitere Artikel + {fallbackText(relatedCopy.heading, blogDetailContent.related.heading)}
{related.map((rel, idx) => ( - 0} isMobile={isMobile} /> + 0} isMobile={isMobile} readMoreLabel={fallbackText(relatedCopy.readMoreLabel, blogDetailContent.related.readMoreLabel)} /> ))}
@@ -277,7 +305,7 @@ export default function BlogDetail() { ); } -function RelatedCard({ article, bordered, isMobile }: { article: ReturnType & object; bordered: boolean; isMobile: boolean }) { +function RelatedCard({ article, bordered, isMobile, readMoreLabel }: { article: ReturnType & object; bordered: boolean; isMobile: boolean; readMoreLabel: string }) { const [hovered, setHovered] = React.useState(false); if (!article) return null; return ( @@ -319,7 +347,7 @@ function RelatedCard({ article, bordered, isMobile }: { article: ReturnType - Weiterlesen + {readMoreLabel} );