feat: manage blog detail chrome via payload
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<T extends boolean = true> {
|
||||
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;
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
42
src/scripts/preload-blog-detail-cms.ts
Normal file
42
src/scripts/preload-blog-detail-cms.ts
Normal 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)
|
||||
})
|
||||
29
src/spa/blogDetailContent.ts
Normal file
29
src/spa/blogDetailContent.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
Innovation: '#2563EB',
|
||||
Engagement: '#16A34A',
|
||||
Wirtschaft: '#9333EA',
|
||||
type BlogDetailCms = Omit<Partial<typeof blogDetailContent>, 'categoryColors'> & {
|
||||
categoryColors?: unknown
|
||||
metaCopy?: Partial<typeof blogDetailContent.meta>
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -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 (
|
||||
<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>
|
||||
<Link to="/presse" style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ArrowLeft size={14} /> Zurück zur Presse
|
||||
<h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
|
||||
<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} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
|
||||
</Link>
|
||||
</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 }}>
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/presse"
|
||||
to={fallbackText(heroBackLink.url, blogDetailContent.heroBackLink.url)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
fontFamily: FF, fontSize: 11, fontWeight: 700,
|
||||
@@ -93,7 +121,7 @@ export default function BlogDetail() {
|
||||
onMouseEnter={e => (e.currentTarget.style.color = '#fff')}
|
||||
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>
|
||||
|
||||
{/* 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() {
|
||||
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
<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}
|
||||
@@ -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={{ 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 }}>
|
||||
Jetzt bewerben
|
||||
{fallbackText(sidebarCta.heading, blogDetailContent.sidebarCta.heading)}
|
||||
</h4>
|
||||
<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>
|
||||
<Link
|
||||
to="/teilnahme"
|
||||
to={fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '13px 20px',
|
||||
@@ -244,7 +272,7 @@ export default function BlogDetail() {
|
||||
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
|
||||
>
|
||||
Zur Bewerbung <ArrowRight size={12} />
|
||||
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,14 +287,14 @@ export default function BlogDetail() {
|
||||
|
||||
<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)' }}>
|
||||
Weitere Artikel
|
||||
{fallbackText(relatedCopy.heading, blogDetailContent.related.heading)}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.1)' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${related.length}, 1fr)`, gap: 0 }}>
|
||||
{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>
|
||||
@@ -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);
|
||||
if (!article) return null;
|
||||
return (
|
||||
@@ -319,7 +347,7 @@ function RelatedCard({ article, bordered, isMobile }: { article: ReturnType<type
|
||||
{article.excerpt}
|
||||
</p>
|
||||
<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>
|
||||
</Link>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user