- 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.
364 lines
18 KiB
TypeScript
364 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|