Files
bmp-website-2026/src/spa/pages/BlogDetail.tsx
syntaxbullet 7ff405d1a2 Add migration scripts and preload participation awards script
- Created migration index file to manage database migrations.
- Added script to preload participation awards and related blog posts into the CMS.
- Introduced new types and default data for application phases.
- Added award articles data structure for the upcoming awards.
2026-06-30 19:10:53 +02:00

374 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 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>
);
}
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
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
style={{
fontFamily: FF,
fontSize: isMobile ? 'clamp(1.6rem, 6vw, 2.4rem)' : 'clamp(2rem, 5vw, 3.8rem)',
fontWeight: 900,
color: '#fff',
lineHeight: 1.05,
letterSpacing: '-0.03em',
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 ? '1fr' : '1fr 320px', gap: isMobile ? 40 : 80 }}>
{/* LEFT – article text */}
<article>
{/* Lead */}
<p style={{
fontFamily: FB, fontSize: 19, fontWeight: 300,
color: INK, lineHeight: 1.75,
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 style={{
fontFamily: FF, fontSize: 'clamp(1.1rem, 1.8vw, 1.35rem)',
fontWeight: 900, color: NAVY,
textTransform: 'uppercase', letterSpacing: '-0.01em',
marginBottom: 16,
}}>
{section.heading}
</h2>
)}
<p style={{
fontFamily: FB, fontSize: 18,
color: 'rgba(58,58,58,0.8)', lineHeight: 1.8, 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>
<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 style={{ fontFamily: FF, fontSize: 15, fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.01em', marginBottom: 12 }}>
{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 }}>
{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 style={{ fontFamily: FF, fontSize: 'clamp(1rem, 1.4vw, 1.15rem)', fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.01em', lineHeight: 1.25, 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 style={{ fontFamily: FB, fontSize: 16, color: 'rgba(255,255,255,0.38)', lineHeight: 1.7, 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>
);
}