Files
bmp-website-2026/src/spa/pages/BlogDetail.tsx
syntaxbullet b57e08940d Refactor typography styles across multiple pages to use CSS classes for consistency and maintainability
- Updated Participation.tsx to replace inline styles with class names for headings and paragraphs.
- Refactored Preistraeger.tsx to utilize CSS classes for typography instead of inline styles.
- Modified PreistraegerDetail.tsx to implement class-based styling for headings and text elements.
- Adjusted Press.tsx to standardize typography using CSS classes, enhancing readability and maintainability.
2026-07-13 21:46:56 +02:00

364 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useParams, Link } from '@/spa/router';
import { ArrowLeft, Clock, User, Tag, ArrowRight } from 'lucide-react';
import { motion } from 'framer-motion';
import { getArticleBySlug, articles, type BlogArticle, type BlogSection } from '@/spa/data/blog';
import { useCmsRoute } from '@/spa/cmsRoute';
import { docImageUrl, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { blogDetailContent } from '@/spa/blogDetailContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const PAGE_BG = '#fff';
const INK = '#3A3A3A';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
type BlogDetailCms = Omit<Partial<typeof blogDetailContent>, 'categoryColors'> & {
fallbackImage?: unknown
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 },
fallbackArticle: { ...blogDetailContent.fallbackArticle, ...detail.fallbackArticle },
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() {
const isMobile = useIsMobile();
const { slug } = useParams<{ slug: string }>();
const route = useCmsRoute();
const cmsArticle = route?.collection === 'posts' ? route.doc : undefined;
const cmsDetail = getDetailPage(cmsArticle?.detailPage);
const fallbackArticle = cmsDetail.fallbackArticle || blogDetailContent.fallbackArticle;
const articleFallbackImage = mediaUrl(cmsDetail.fallbackImage, `/images/${blogDetailContent.fallbackImageFilename}`);
const article = (slug ? getArticleBySlug(slug) : undefined) || (cmsArticle ? {
slug: String(cmsArticle.slug || slug),
title: String(cmsArticle.title || fallbackArticle.title || blogDetailContent.fallbackArticle.title),
cat: String(cmsArticle.cat || fallbackArticle.category || blogDetailContent.fallbackArticle.category),
date: String(cmsArticle.date || fallbackArticle.date || blogDetailContent.fallbackArticle.date),
author: String(cmsArticle.authorName || fallbackArticle.authorName || blogDetailContent.fallbackArticle.authorName),
authorRole: String(cmsArticle.authorRole || fallbackArticle.authorRole || blogDetailContent.fallbackArticle.authorRole),
readTime: String(cmsArticle.readTime || fallbackArticle.readTime || blogDetailContent.fallbackArticle.readTime),
img: docImageUrl(cmsArticle, articleFallbackImage, 'heroImage'),
excerpt: String(cmsArticle.excerpt || cmsArticle.meta?.description || fallbackArticle.excerpt || blogDetailContent.fallbackArticle.excerpt),
sections: Array.isArray(cmsArticle.sections) && cmsArticle.sections.length > 0 ? cmsArticle.sections as BlogSection[] : [{ body: String(cmsArticle.excerpt || cmsArticle.meta?.description || fallbackArticle.excerpt || blogDetailContent.fallbackArticle.excerpt) }],
tags: Array.isArray(cmsArticle.tags)
? cmsArticle.tags
.map((item: { tag?: string }) => item.tag)
.filter((tag): tag is string => Boolean(tag))
: [String(fallbackArticle.tag || blogDetailContent.fallbackArticle.tag)],
relatedSlugs: Array.isArray(cmsArticle.relatedSlugs)
? cmsArticle.relatedSlugs
.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 : cmsDetail);
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;
const sidebarCtaUrl = fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url);
const sidebarCtaIsExternal = /^(mailto:|tel:|https?:)/.test(sidebarCtaUrl);
const sidebarCtaStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '13px 20px',
background: NAVY, color: '#fff',
fontFamily: FF, fontSize: 14, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.1em',
textDecoration: 'none',
transition: 'background 0.15s',
};
if (!article) {
return (
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: PAGE_BG }}>
<h2 className="type-section-title text-role-heading">{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>
);
}
const related = article.relatedSlugs
.map(s => articles.find(a => a.slug === s))
.filter(Boolean) as typeof articles;
return (
<div>
{/* ── HERO ──────────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: isMobile ? 'auto' : '70vh', minHeight: 520, overflow: 'hidden', display: 'flex', alignItems: 'flex-end' }}>
<Image unoptimized
src={article.img}
alt={article.title}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%' }}
/>
{/* Gradient overlay */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, #111D55 0%, rgba(3,9,58,0.75) 45%, rgba(3,9,58,0.1) 100%)' }} />
{/* Gold bottom line */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.3), transparent)` }} />
<div style={{ position: 'relative', zIndex: 2, padding: isMobile ? '0 24px 48px' : '0 80px 72px', maxWidth: 860 }}>
{/* Back link */}
<Link
to={fallbackText(heroBackLink.url, blogDetailContent.heroBackLink.url)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: FF, fontSize: 11, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.14em',
color: 'rgba(255,255,255,0.5)', textDecoration: 'none',
marginBottom: 32, transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = '#fff')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.5)')}
>
<ArrowLeft size={13} /> {fallbackText(heroBackLink.label, blogDetailContent.heroBackLink.label)}
</Link>
{/* Category badge */}
<div style={{ marginBottom: 20 }}>
<span style={{
display: 'inline-block',
padding: '4px 12px',
fontFamily: FF, fontSize: 9, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.22em',
background: categoryColors[article.cat] ?? GOLD,
color: '#fff',
}}>
{article.cat}
</span>
</div>
<motion.h1
className="type-page-title text-role-inverse"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
style={{
textTransform: 'uppercase',
margin: '0 0 28px',
maxWidth: 760,
}}
>
{article.title}
</motion.h1>
{/* Meta row */}
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap' }}>
{[
{ icon: User, label: `${article.author} · ${article.authorRole}` },
{ 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}
</div>
))}
</div>
</div>
</section>
{/* ── ARTICLE BODY ─────────────────────────────────────────────────── */}
<section style={{ background: PAGE_BG, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) 320px', gap: isMobile ? 40 : 80 }}>
{/* LEFT – article text */}
<article style={{ minWidth: 0 }}>
{/* Lead */}
<p className="type-lead text-role-body" style={{
borderLeft: `4px solid ${GOLD}`, paddingLeft: 28,
marginBottom: 56,
}}>
{article.excerpt}
</p>
{/* Sections */}
{article.sections.map((section, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: idx * 0.04 }}
style={{ marginBottom: 48 }}
>
{section.heading && (
<h2 className="type-subtitle text-role-heading" style={{
textTransform: 'uppercase',
marginBottom: 16,
}}>
{section.heading}
</h2>
)}
<p className="type-body text-role-body" style={{
margin: 0,
}}>
{section.body}
</p>
</motion.div>
))}
{/* Tags */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', padding: '40px 0', borderTop: '1px solid rgba(3,9,58,0.1)', marginTop: 24 }}>
<Tag size={13} color='rgba(3,9,58,0.35)' style={{ marginTop: 2 }} />
{article.tags.map(tag => (
<span key={tag} style={{
padding: '4px 12px',
fontFamily: FF, fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.12em',
color: NAVY, background: 'rgba(3,9,58,0.07)',
}}>
{tag}
</span>
))}
</div>
</article>
{/* RIGHT – sticky sidebar */}
<aside style={{ minWidth: 0 }}>
<div style={{ position: isMobile ? 'static' : 'sticky', top: isMobile ? undefined : 100 }}>
{/* Author card */}
<div style={{ background: NAVY, padding: '28px 28px 32px', marginBottom: 24 }}>
<div style={{ width: 48, height: 48, background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 16 }}>
<User size={22} color={NAVY} />
</div>
<div style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', marginBottom: 4 }}>
{article.author}
</div>
<div style={{ fontFamily: FB, fontSize: 15, color: 'rgba(255,255,255,0.45)', lineHeight: 1.6 }}>
{article.authorRole}
</div>
</div>
{/* CTA */}
<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 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 12 }}>
{fallbackText(sidebarCta.heading, blogDetailContent.sidebarCta.heading)}
</h4>
<p className="type-small text-role-secondary" style={{ marginBottom: 20 }}>
{fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
</p>
{sidebarCtaIsExternal ? (
<a
href={sidebarCtaUrl}
style={sidebarCtaStyle}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</a>
) : (
<Link
to={sidebarCtaUrl}
style={sidebarCtaStyle}
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
>
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
</Link>
)}
</div>
</div>
</aside>
</div>
</section>
{/* ── RELATED ARTICLES ─────────────────────────────────────────────── */}
{related.length > 0 && (
<section style={{ background: NAVY, padding: isMobile ? '48px 0' : '80px 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px' }}>
<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)' }}>
{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} readMoreLabel={fallbackText(relatedCopy.readMoreLabel, blogDetailContent.related.readMoreLabel)} />
))}
</div>
</div>
</section>
)}
</div>
);
}
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 (
<Link
to={`/presse/blog/${article.slug}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex', flexDirection: 'column',
borderLeft: bordered && !isMobile ? '1px solid rgba(255,255,255,0.07)' : 'none',
borderBottom: 'none',
textDecoration: 'none',
paddingLeft: bordered && !isMobile ? 40 : 0,
paddingRight: isMobile ? 0 : 40,
paddingBottom: isMobile ? 40 : 0,
}}
>
{/* Thumbnail */}
<div style={{ height: 180, overflow: 'hidden', marginBottom: 24 }}>
<Image unoptimized
src={article.img}
alt={article.title}
style={{
width: '100%', height: '100%', objectFit: 'cover',
filter: hovered ? 'none' : 'grayscale(80%)',
transform: hovered ? 'scale(1.04)' : 'scale(1)',
transition: 'filter 0.4s, transform 0.4s',
}}
/>
</div>
<span style={{ fontFamily: FF, fontSize: 9, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.22em', fontWeight: 700, marginBottom: 10 }}>
{article.cat}
</span>
<h3 className="type-card-title text-role-inverse" style={{ textTransform: 'uppercase', marginBottom: 12 }}>
{article.title}
</h3>
<div style={{ width: hovered ? 64 : 24, height: 1, background: 'rgba(239,191,4,0.5)', transition: 'width 0.35s', marginBottom: 14 }} />
<p className="type-small text-role-inverse-muted" style={{ flex: 1, marginBottom: 16 }}>
{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 }}>
{readMoreLabel} <ArrowRight size={12} />
</span>
</Link>
);
}