Files
bmp-website-2026/src/spa/pages/Press.tsx
syntaxbullet d2fdf8e3c5 feat: add JuryMitglieder collection and migration for jury members
- Created a new collection `JuryMitglieder` with fields for name, image, quote, role, organization, bio, website, LinkedIn, sort order, active status, and published date.
- Implemented access control for creating, reading, updating, and deleting jury members.
- Added migration script to create necessary database tables and columns for jury members, including versioning support.
- Updated existing `preistraeger` table to include new columns for award group, winner rank, sort order, and visibility settings.
- Introduced a temporary global switch to control the visibility of testimonials in the SPA.
2026-07-07 14:57:15 +02:00

555 lines
22 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 { Calendar, MapPin } from 'lucide-react';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { pressIndexContent } from '@/spa/pressIndexContent';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
type PressIndexCms = Partial<typeof pressIndexContent> & {
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown; fallbackItems?: PressEventItem[] }
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown; fallbackItems?: PressNewsItem[] }
downloads?: Partial<typeof pressIndexContent.downloads> & { kitFile?: unknown }
}
type PressEventItem = (typeof pressIndexContent.events.fallbackItems)[number] & { image?: unknown }
type PressNewsItem = (typeof pressIndexContent.news.fallbackItems)[number] & { image?: unknown }
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
type StatusLabel = {
value?: string | null
label?: string | null
}
const statusLabelMap = (items: unknown) =>
fallbackArray<StatusLabel>(items, pressIndexContent.events.statusLabels).reduce<Record<string, string>>((labels, item) => {
const value = fallbackText(item.value, '')
const label = fallbackText(item.label, '')
if (value && label) labels[value] = label
return labels
}, {})
const normalizeStatus = (status: unknown, labels: Record<string, string>, defaultStatusLabel: string) => {
const value = fallbackText(status, defaultStatusLabel)
return labels[value] || value
}
const eventFromCms = (
event: CmsRouteDoc,
fallbackImage: string,
labels: Record<string, string>,
eventsSection: NonNullable<PressIndexCms['events']>,
): PressEventItem => ({
title: String(event.title || fallbackText(eventsSection.missingTitleLabel, pressIndexContent.events.missingTitleLabel)),
date: String(event.date || fallbackText(eventsSection.missingDateLabel, pressIndexContent.events.missingDateLabel)),
location: String(event.location || event.venue || fallbackText(eventsSection.missingLocationLabel, pressIndexContent.events.missingLocationLabel)),
cat: String(event.category || fallbackText(eventsSection.missingCategoryLabel, pressIndexContent.events.missingCategoryLabel)),
status: normalizeStatus(event.status, labels, fallbackText(eventsSection.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel)),
img: docImageUrl(event, fallbackImage),
desc: String(event.description || event.meta?.description || ''),
slug: String(event.spaPath || `/presse/events/${event.slug}`),
})
const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonNullable<PressIndexCms['news']>): PressNewsItem => ({
title: String(post.title || fallbackText(newsSection.missingTitleLabel, pressIndexContent.news.missingTitleLabel)),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(post.cat || fallbackText(newsSection.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel)),
img: docImageUrl(post, fallbackImage, 'heroImage'),
slug: String(post.spaPath || `/presse/blog/${post.slug}`),
})
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>
}
const fallbackCardImage = (item: PressEventItem | PressNewsItem) => mediaUrl(item.image, fallbackText(item.img, ''))
function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: number; isMobile: boolean; total: number }) {
const [hovered, setHovered] = React.useState(false);
return (
<article
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
borderLeft: isMobile ? 'none' : (idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none'),
borderBottom: isMobile && idx < total - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
}}
>
{/* image */}
<div style={{ position: 'relative', overflow: 'hidden', height: 220 }}>
<Image unoptimized
src={fallbackCardImage(item)}
alt={item.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
filter: hovered ? 'none' : 'grayscale(100%)',
transition: 'filter 0.5s',
}}
/>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '50%',
background: 'linear-gradient(to top, rgba(3,9,58,0.8), transparent)',
}}
/>
</div>
{/* body */}
<div style={{ padding: isMobile ? '24px 24px 32px' : '32px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
<span
style={{
fontFamily: FF,
fontSize: 9,
color: GOLD,
textTransform: 'uppercase',
letterSpacing: '0.22em',
fontWeight: 700,
marginBottom: 12,
display: 'block',
}}
>
{item.cat}
</span>
<h3
style={{
fontFamily: FF,
fontSize: 'clamp(1rem, 1.5vw, 1.2rem)',
fontWeight: 700,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.01em',
lineHeight: 1.25,
marginBottom: 16,
}}
>
{item.title}
</h3>
<div
style={{
width: hovered ? '100%' : 24,
height: 1,
background: 'rgba(239,191,4,0.5)',
transition: 'width 0.4s ease',
marginBottom: 16,
}}
/>
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.4)',
lineHeight: 1.7,
flex: 1,
marginBottom: 20,
}}
>
{item.excerpt}
</p>
</div>
</article>
);
}
const Press: React.FC = () => {
const isMobile = useIsMobile();
const cms = (useCmsRoute()?.doc?.pressIndex || {}) as PressIndexCms;
const hero = React.useMemo(() => ({ ...pressIndexContent.hero, ...(cms.hero || {}) }), [cms.hero]);
const eventsSection = React.useMemo(() => ({ ...pressIndexContent.events, ...(cms.events || {}) }), [cms.events]);
const newsSection = React.useMemo(() => ({ ...pressIndexContent.news, ...(cms.news || {}) }), [cms.news]);
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
const cmsEvents = useCmsCollection('events');
const cmsPosts = useCmsCollection('posts');
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
const eventCollectionFallbackImage = mediaUrl(eventsSection.collectionFallbackImage, `/images/${pressIndexContent.events.collectionFallbackImageFilename}`);
const newsCollectionFallbackImage = mediaUrl(newsSection.collectionFallbackImage, `/images/${pressIndexContent.news.collectionFallbackImageFilename}`);
const combinedEvents = React.useMemo(() => {
if (cmsEvents.length) {
return cmsEvents.map((event) =>
eventFromCms(
event,
eventCollectionFallbackImage,
eventStatusLabels,
eventsSection,
),
);
}
return fallbackArray<PressEventItem>(eventsSection.fallbackItems, pressIndexContent.events.fallbackItems);
}, [cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection]);
const combinedNews = React.useMemo(() => {
if (cmsPosts.length) return cmsPosts.map((post) => postFromCms(post, newsCollectionFallbackImage, newsSection));
return fallbackArray<PressNewsItem>(newsSection.fallbackItems, pressIndexContent.news.fallbackItems);
}, [cmsPosts, newsCollectionFallbackImage, newsSection]);
return (
<div className="animate-fade-in">
{/* ── 1. HERO ─────────────────────────────────────────────────────── */}
<section
style={{
position: 'relative',
minHeight: '65vh',
display: 'flex',
alignItems: 'flex-end',
overflow: 'hidden',
background: NAVY,
}}
>
<Image unoptimized
src={mediaUrl(hero.image, `/images/${pressIndexContent.hero.imageFilename}`)}
alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, pressIndexContent.hero.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
<div
style={{
position: 'absolute',
inset: 0,
background: isMobile
? 'linear-gradient(to top, rgba(2,9,48,0.95) 0%, rgba(2,9,48,0.72) 38%, rgba(2,9,48,0.22) 72%, transparent 100%)'
: 'linear-gradient(to right, #020930 0%, rgba(2,9,48,0.90) 38%, rgba(2,9,48,0.18) 65%, transparent 100%)',
}}
/>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 2,
background: 'linear-gradient(to right, #EFBF04, rgba(239,191,4,0.3), transparent)',
}}
/>
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '0 24px 48px' : '0 80px 80px', maxWidth: 860 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 24 }}>
<div style={{ width: 36, height: 2, background: GOLD }} />
<span
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.32em',
textTransform: 'uppercase',
color: GOLD,
}}
>
{fallbackText(hero.eyebrow, pressIndexContent.hero.eyebrow)}
</span>
</div>
<h1
style={{
fontFamily: FF,
fontSize: isMobile ? 'clamp(2rem, 8vw, 2.8rem)' : 'clamp(2.8rem, 6vw, 5rem)',
fontWeight: 900,
color: '#fff',
lineHeight: 0.95,
letterSpacing: '-0.03em',
textTransform: 'uppercase',
margin: '0 0 24px',
overflowWrap: 'anywhere',
}}
>
<Lines text={fallbackText(hero.heading, pressIndexContent.hero.heading)} />
</h1>
<div style={{ width: 48, height: 2, background: GOLD, marginBottom: 24 }} />
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.6)',
fontWeight: 300,
maxWidth: 520,
lineHeight: 1.7,
margin: 0,
}}
>
{fallbackText(hero.description, pressIndexContent.hero.description)}
</p>
</div>
</section>
{/* ── 2. EVENTS ───────────────────────────────────────────────────── */}
<section id="events" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
{/* Section header */}
<div
style={{
padding: isMobile ? '48px 24px 40px' : '80px 80px 56px',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
gap: isMobile ? 24 : 48,
alignItems: 'flex-end',
borderBottom: '1px solid rgba(3,9,58,0.1)',
}}
>
<div>
<div
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: '#4A8FC9',
marginBottom: 16,
}}
>
{fallbackText(eventsSection.eyebrow, pressIndexContent.events.eyebrow)}
</div>
<h2
style={{
fontFamily: FF,
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
fontSize: 'clamp(2rem, 3.5vw, 3rem)',
margin: 0,
}}
>
<Lines text={fallbackText(eventsSection.heading, pressIndexContent.events.heading)} />
</h2>
</div>
<div>
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(16,24,40,0.45)',
lineHeight: 1.8,
margin: 0,
}}
>
{fallbackText(eventsSection.description, pressIndexContent.events.description)}
</p>
</div>
</div>
{/* Event rows */}
{combinedEvents.map((event, idx) => (
<div
key={idx}
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '320px 1fr',
borderBottom: '1px solid rgba(3,9,58,0.08)',
background: 'transparent',
}}
>
{/* Left – image cell */}
<div style={{ position: 'relative', overflow: 'hidden', height: isMobile ? 200 : 240 }}>
<Image unoptimized src={fallbackCardImage(event)} alt={event.title} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
<div style={{ position: 'absolute', top: 20, left: 20, padding: '5px 12px', fontFamily: FF, fontSize: 9, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.18em', background: event.status === fallbackText(eventsSection.openStatusLabel, pressIndexContent.events.openStatusLabel) ? GOLD : 'rgba(3,9,58,0.85)', color: event.status === fallbackText(eventsSection.openStatusLabel, pressIndexContent.events.openStatusLabel) ? NAVY : 'rgba(255,255,255,0.6)' }}>
{event.status}
</div>
</div>
{/* Right – copy cell */}
<div style={{ padding: isMobile ? '24px 24px 32px' : '40px 56px 40px 48px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div style={{ fontFamily: FF, fontSize: 9, color: '#4A8FC9', textTransform: 'uppercase' as const, letterSpacing: '0.22em', fontWeight: 700, marginBottom: 10 }}>{event.cat}</div>
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.3rem, 2vw, 1.7rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase' as const, letterSpacing: '-0.02em', margin: '0 0 14px' }}>{event.title}</h3>
<div style={{ display: 'flex', gap: isMobile ? 16 : 28, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)' }}>
<Calendar size={14} color={GOLD} />{event.date}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)' }}>
<MapPin size={14} color={GOLD} />{event.location}
</div>
</div>
<div style={{ width: 24, height: 1, background: 'rgba(239,191,4,0.4)', marginBottom: 14 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, flex: 1 }}>{event.desc}</p>
</div>
</div>
))}
</section>
{/* ── 3. NEWSROOM ─────────────────────────────────────────────────── */}
<section style={{ background: NAVY, overflow: 'hidden' }}>
{/* Section header */}
<div
style={{
padding: isMobile ? '48px 24px 40px' : '80px 80px 56px',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
gap: isMobile ? 24 : 48,
alignItems: 'flex-end',
borderBottom: '1px solid rgba(255,255,255,0.07)',
}}
>
<div>
<div
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: GOLD,
marginBottom: 16,
}}
>
{fallbackText(newsSection.eyebrow, pressIndexContent.news.eyebrow)}
</div>
<h2
style={{
fontFamily: FF,
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
fontSize: 'clamp(2rem, 3.5vw, 3rem)',
margin: 0,
}}
>
<Lines text={fallbackText(newsSection.heading, pressIndexContent.news.heading)} />
</h2>
</div>
<div>
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.4)',
lineHeight: 1.8,
margin: 0,
}}
>
{fallbackText(newsSection.description, pressIndexContent.news.description)}
</p>
</div>
</div>
{/* 3-column news grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
{combinedNews.map((item, idx) => (
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
))}
</div>
</section>
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
<section id="downloads" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
{/* Left col */}
<div
style={{
background: '#fff',
padding: isMobile ? '48px 24px' : '88px 80px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<div
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: '#4A8FC9',
marginBottom: 16,
}}
>
{fallbackText(downloads.eyebrow, pressIndexContent.downloads.eyebrow)}
</div>
<h2
style={{
fontFamily: FF,
fontWeight: 900,
color: '#101828',
textTransform: 'uppercase',
letterSpacing: '-0.025em',
fontSize: 'clamp(1.8rem, 3vw, 2.6rem)',
margin: '0 0 20px',
}}
>
<Lines text={fallbackText(downloads.heading, pressIndexContent.downloads.heading)} />
</h2>
{/* Gold divider */}
<div style={{ width: 40, height: 2, background: GOLD, margin: '0 0 28px' }} />
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(16,24,40,0.5)',
lineHeight: 1.8,
maxWidth: 380,
marginBottom: 0,
}}
>
{fallbackText(downloads.description, pressIndexContent.downloads.description)}
</p>
{/* Press contact block */}
<div style={{ borderLeft: `3px solid ${GOLD}`, paddingLeft: 24, marginTop: 32 }}>
<div
style={{
fontFamily: FF,
fontSize: 10,
color: '#4A8FC9',
textTransform: 'uppercase',
letterSpacing: '0.2em',
fontWeight: 700,
marginBottom: 12,
}}
>
{fallbackText(downloads.contactEyebrow, pressIndexContent.downloads.contactEyebrow)}
</div>
<div
style={{
fontFamily: FF,
fontSize: 18,
fontWeight: 700,
color: '#101828',
marginBottom: 4,
}}
>
{fallbackText(downloads.contactName, pressIndexContent.downloads.contactName)}
</div>
<div
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(16,24,40,0.55)',
lineHeight: 1.8,
overflowWrap: 'anywhere',
}}
>
{contactLines.map((line, index) => (
<React.Fragment key={`${line}-${index}`}>
{line}
{index < contactLines.length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
</div>
</div>
</section>
</div>
);
};
export default Press;