Files
bmp-website-2026/src/spa/pages/PreistraegerDetail.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

392 lines
21 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 { Link, useParams, Navigate } from '@/spa/router';
import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
import { WINNERS } from '@/spa/data/winners';
import { useCmsRoute } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent';
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder';
import Image from '@/spa/components/ui/UnoptimizedImage'
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const BORDER = '#D0D5DD';
const GRAY = '#666666';
const BG_ALT = '#E4E2E3';
type PreistraegerDetailCms = Partial<typeof preistraegerDetailContent> & {
fallbackImage?: unknown
sideImage?: Partial<typeof preistraegerDetailContent.sideImage> & { image?: unknown }
}
type GalleryImageCms = {
id?: string | null
image?: unknown
imageAlt?: string | null
caption?: string | null
}
type PreistraegerGalleryCms = {
enabled?: boolean | null
heading?: string | null
description?: string | null
images?: GalleryImageCms[] | null
}
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const mediaFilename = (media: unknown) => {
if (!media || typeof media !== 'object') return undefined;
const filename = (media as { filename?: unknown }).filename;
return typeof filename === 'string' && filename.length > 0 ? filename : undefined;
};
export default function PreistraegerDetail() {
const { slug } = useParams<{ slug: string }>();
const route = useCmsRoute();
const placeholderImage = usePreistraegerPlaceholderImage();
const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined;
const cmsDetail = {
...preistraegerDetailContent,
...((cmsWinner?.detailPage || {}) as PreistraegerDetailCms),
} as PreistraegerDetailCms;
const fallbackWinner = cmsDetail.fallbackWinner || preistraegerDetailContent.fallbackWinner;
const cmsWinnerIsGoldeneBavaria = cmsWinner?.awardGroup === 'goldene-bavaria';
const winner = cmsWinner ? {
id: String(cmsWinner.id),
slug: String(cmsWinner.slug || slug),
name: String(cmsWinner.title || fallbackWinner.name || preistraegerDetailContent.fallbackWinner.name),
category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category),
year: Number(cmsWinner.year || new Date().getFullYear()),
type: String(cmsWinner.awardType || (cmsWinnerIsGoldeneBavaria ? 'Goldene Bavaria' : fallbackWinner.awardType) || preistraegerDetailContent.fallbackWinner.awardType),
img: docImageUrl(cmsWinner, placeholderImage),
shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''),
longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || fallbackWinner.longDesc || preistraegerDetailContent.fallbackWinner.longDesc),
quote: String(cmsWinner.quote || fallbackWinner.quote || preistraegerDetailContent.fallbackWinner.quote),
quotePerson: String(cmsWinner.quotePerson || cmsWinner.title || fallbackWinner.quotePerson || preistraegerDetailContent.fallbackWinner.quotePerson),
quoteRole: String(cmsWinner.quoteRole || fallbackWinner.quoteRole || preistraegerDetailContent.fallbackWinner.quoteRole),
location: String(cmsWinner.location || fallbackWinner.location || preistraegerDetailContent.fallbackWinner.location),
industry: String(cmsWinner.industry || fallbackWinner.industry || preistraegerDetailContent.fallbackWinner.industry),
website: String(cmsWinner.website || '#'),
hasMedia: Boolean(cmsWinner.hasMedia),
detailPage: (cmsWinner.detailPage || {}) as PreistraegerDetailCms,
} : WINNERS.find(w => w.slug === slug);
const isMobile = useIsMobile();
const detail = (winner && 'detailPage' in winner ? { ...preistraegerDetailContent, ...(winner.detailPage || {}) } : cmsDetail) as PreistraegerDetailCms;
const breadcrumb = { ...preistraegerDetailContent.breadcrumb, ...(detail.breadcrumb || {}) };
const sections = { ...preistraegerDetailContent.sections, ...(detail.sections || {}) };
const sideImage = { ...preistraegerDetailContent.sideImage, ...(detail.sideImage || {}) };
const facts = { ...preistraegerDetailContent.facts, ...(detail.facts || {}) };
const websiteCta = { ...preistraegerDetailContent.websiteCta, ...(detail.websiteCta || {}) };
const backLink = { ...preistraegerDetailContent.backLink, ...(detail.backLink || {}) };
const downloadHref = mediaUrl(cmsWinner?.downloadZip, '');
const downloadFilename = mediaFilename(cmsWinner?.downloadZip);
const downloadLabel = fallbackText(cmsWinner?.downloadLabel, 'Pressepaket herunterladen');
const gallery = (cmsWinner?.gallery || {}) as PreistraegerGalleryCms;
const galleryImages = Array.isArray(gallery.images)
? gallery.images
.map((item) => ({
id: item.id || mediaFilename(item.image) || String(mediaUrl(item.image, '')),
src: mediaUrl(item.image, ''),
alt: mediaAlt(item.image, fallbackText(item.imageAlt, fallbackText(item.caption, winner?.name || 'Galeriebild'))),
caption: fallbackText(item.caption, ''),
}))
.filter((item) => item.src)
: [];
const [activeGalleryIndex, setActiveGalleryIndex] = React.useState<number | undefined>(undefined);
const activeGalleryImage = typeof activeGalleryIndex === 'number' ? galleryImages[activeGalleryIndex] : undefined;
const showGallery = Boolean(gallery.enabled) && galleryImages.length > 0;
if (!winner) return <Navigate to={preistraegerDetailContent.notFoundRedirect} replace />;
return (
<div style={{ background: '#fff', minHeight: '100vh' }}>
{/* Hero */}
<div style={{ width: '100%', height: '60vh', overflow: 'hidden', position: 'relative' }}>
<Image unoptimized
src={winner.img}
alt={winner.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, transparent 50%, rgba(3,9,58,0.82) 100%)' }} />
</div>
{/* Breadcrumb */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '16px 24px' : '16px 80px', display: 'flex', alignItems: 'center', gap: 8 }}>
<Link to={fallbackText(breadcrumb.url, preistraegerDetailContent.breadcrumb.url)} style={{ fontFamily: FF, fontSize: 16, color: GOLD, textDecoration: 'none', fontWeight: 600 }}>
{fallbackText(breadcrumb.label, preistraegerDetailContent.breadcrumb.label)}
</Link>
<ChevronRight size={14} style={{ color: GRAY }} />
<span style={{ fontFamily: FF, fontSize: 16, color: GRAY }}>{winner.name}</span>
</div>
{/* Header block */}
<div style={{ padding: isMobile ? '32px 24px 28px' : '48px 80px 40px', borderBottom: `1px solid ${BORDER}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16, flexWrap: 'wrap' }}>
<span style={{
fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#fff',
background: NAVY, padding: '4px 14px', borderRadius: 999,
textTransform: 'uppercase', letterSpacing: '0.1em',
}}>
{winner.category}
</span>
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: GOLD }}>
{winner.year}
</span>
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{winner.type}
</span>
</div>
<h1 style={{ fontFamily: FF, fontSize: isMobile ? 'clamp(1.6rem, 5vw, 3rem)' : 'clamp(2rem, 5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', margin: '0 0 12px' }}>
{winner.name}
</h1>
<p style={{ fontFamily: FB, fontSize: 18, color: GRAY, margin: 0, maxWidth: 640, lineHeight: 1.6 }}>
{winner.shortDesc}
</p>
</div>
{/* Content 2-col */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 1fr', gap: isMobile ? 40 : 64 }}>
{/* Left: Text */}
<div>
<h2 style={{ fontFamily: FF, fontSize: 18, fontWeight: 800, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 20 }}>
{fallbackText(sections.companyHeading, preistraegerDetailContent.sections.companyHeading)}
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: '#333', lineHeight: 1.8, marginBottom: 40 }}>
{winner.longDesc}
</p>
<h2 style={{ fontFamily: FF, fontSize: 18, fontWeight: 800, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 20 }}>
{fallbackText(sections.juryHeading, preistraegerDetailContent.sections.juryHeading)}
</h2>
<p style={{ fontFamily: FB, fontSize: 18, color: '#333', lineHeight: 1.8, marginBottom: 40 }}>
{fallbackText(sections.juryTextBeforeName, preistraegerDetailContent.sections.juryTextBeforeName)} {winner.name} {fallbackText(sections.juryTextAfterNameBeforeCategory, preistraegerDetailContent.sections.juryTextAfterNameBeforeCategory)} <strong>{winner.category}</strong>. {fallbackText(sections.juryTextAfterCategory, preistraegerDetailContent.sections.juryTextAfterCategory)}
</p>
{/* Quote */}
<div style={{ borderLeft: `4px solid ${GOLD}`, paddingLeft: 24, marginBottom: 40 }}>
<p style={{ fontFamily: FB, fontStyle: 'italic', fontSize: 18, color: '#101828', lineHeight: 1.7, marginBottom: 12 }}>
<span style={{ color: GOLD, fontSize: 28, lineHeight: 1, marginRight: 4, fontFamily: '"IBM Plex Sans"' }}>&ldquo;</span>
{winner.quote}
<span style={{ color: GOLD, fontSize: 28, lineHeight: 1, marginLeft: 2, fontFamily: '"IBM Plex Sans"' }}>&rdquo;</span>
</p>
<p style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: GRAY, margin: 0, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
– {winner.quotePerson}, {winner.quoteRole}
</p>
</div>
</div>
{/* Right: Photo + Info box */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
{/* Photo */}
<div style={{ borderRadius: 0, overflow: 'hidden', aspectRatio: '4/3' }}>
<Image unoptimized src={mediaUrl(sideImage.image, `/${preistraegerDetailContent.sideImage.imageFilename}`)} alt={mediaAlt(sideImage.image, fallbackText(sideImage.imageAlt, preistraegerDetailContent.sideImage.imageAlt))} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', filter: 'brightness(0.95)' }} />
</div>
{/* Kenndaten */}
<div style={{ background: BG_ALT, borderLeft: `3px solid ${GOLD}`, padding: '20px 24px' }}>
<h3 style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: GRAY, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 20 }}>
{fallbackText(facts.heading, preistraegerDetailContent.facts.heading)}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{[
{ label: fallbackText(facts.categoryLabel, preistraegerDetailContent.facts.categoryLabel), value: winner.category, icon: <Trophy size={14} style={{ color: GOLD }} /> },
{ label: fallbackText(facts.awardLabel, preistraegerDetailContent.facts.awardLabel), value: winner.type, icon: <Trophy size={14} style={{ color: GOLD }} /> },
{ label: fallbackText(facts.yearLabel, preistraegerDetailContent.facts.yearLabel), value: String(winner.year), icon: null },
{ label: fallbackText(facts.locationLabel, preistraegerDetailContent.facts.locationLabel), value: winner.location, icon: <MapPin size={14} style={{ color: GOLD }} /> },
{ label: fallbackText(facts.industryLabel, preistraegerDetailContent.facts.industryLabel), value: winner.industry, icon: <Building2 size={14} style={{ color: GOLD }} /> },
].map(row => (
<div key={row.label} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontFamily: FF, fontSize: 11, color: GRAY, textTransform: 'uppercase', letterSpacing: '0.08em' }}>{row.label}</span>
<span style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', overflowWrap: 'anywhere' }}>{row.value}</span>
</div>
))}
</div>
</div>
{/* Website button */}
<a
href={`https://${winner.website}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: FF, fontSize: 16, fontWeight: 700,
color: '#101828', border: `2px solid ${NAVY}`,
padding: '12px 20px', textDecoration: 'none',
textTransform: 'uppercase', letterSpacing: '0.08em',
transition: 'all 0.2s', borderRadius: 0, overflowWrap: 'anywhere',
}}
onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.background = NAVY; el.style.color = '#fff'; }}
onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.background = 'transparent'; el.style.color = NAVY; }}
>
<Globe size={16} /> {fallbackText(websiteCta.label, preistraegerDetailContent.websiteCta.label)}
</a>
{downloadHref ? (
<a
href={downloadHref}
download={downloadFilename}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: FF, fontSize: 16, fontWeight: 700,
color: '#101828', border: `2px solid ${GOLD}`,
padding: '12px 20px', textDecoration: 'none',
textTransform: 'uppercase', letterSpacing: '0.08em',
transition: 'all 0.2s', borderRadius: 0, overflowWrap: 'anywhere',
}}
onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.background = GOLD; el.style.color = '#101828'; }}
onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.background = 'transparent'; el.style.color = NAVY; }}
>
<Download size={16} /> {downloadLabel}
</a>
) : null}
</div>
</div>
{showGallery ? (
<section style={{ borderTop: `1px solid ${BORDER}`, background: '#fff' }}>
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px' }}>
<div style={{ marginBottom: 28, maxWidth: 760 }}>
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 26 : 34, fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.02em', margin: '0 0 12px' }}>
{fallbackText(gallery.heading, 'Bildergalerie')}
</h2>
{gallery.description ? (
<p style={{ fontFamily: FB, fontSize: 17, color: GRAY, lineHeight: 1.7, margin: 0 }}>
{gallery.description}
</p>
) : null}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: isMobile ? 14 : 18,
}}
>
{galleryImages.map((image, index) => (
<button
key={image.id || `${image.src}-${index}`}
type="button"
onClick={() => setActiveGalleryIndex(index)}
aria-label={`${image.alt} öffnen`}
style={{
appearance: 'none',
border: `1px solid ${BORDER}`,
background: '#fff',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
}}
>
<span style={{ display: 'block', aspectRatio: '4/3', background: BG_ALT, overflow: 'hidden' }}>
<Image
unoptimized
src={image.src}
alt={image.alt}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</span>
{image.caption ? (
<span style={{ display: 'block', fontFamily: FB, fontSize: 15, lineHeight: 1.5, color: '#333', padding: '12px 14px', overflowWrap: 'anywhere' }}>
{image.caption}
</span>
) : null}
</button>
))}
</div>
</div>
</section>
) : null}
{activeGalleryImage ? (
<div
role="dialog"
aria-modal="true"
aria-label={activeGalleryImage.alt}
onClick={() => setActiveGalleryIndex(undefined)}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(3,9,58,0.92)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: isMobile ? 18 : 40,
}}
>
<button
type="button"
onClick={() => setActiveGalleryIndex(undefined)}
aria-label="Galerie schließen"
style={{ position: 'absolute', top: 18, right: 18, width: 44, height: 44, border: `1px solid rgba(255,255,255,0.28)`, background: 'rgba(255,255,255,0.08)', color: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer' }}
>
<X size={22} />
</button>
{galleryImages.length > 1 ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setActiveGalleryIndex((activeGalleryIndex ?? 0) === 0 ? galleryImages.length - 1 : (activeGalleryIndex ?? 0) - 1);
}}
aria-label="Vorheriges Bild"
style={{ position: 'absolute', left: isMobile ? 12 : 24, top: '50%', transform: 'translateY(-50%)', width: 44, height: 44, border: `1px solid rgba(255,255,255,0.28)`, background: 'rgba(255,255,255,0.08)', color: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer' }}
>
<ChevronLeft size={24} />
</button>
) : null}
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 'min(1120px, 100%)', maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 14 }}>
<Image
unoptimized
src={activeGalleryImage.src}
alt={activeGalleryImage.alt}
style={{ maxWidth: '100%', maxHeight: activeGalleryImage.caption ? '76vh' : '84vh', objectFit: 'contain', display: 'block' }}
/>
{activeGalleryImage.caption ? (
<p style={{ fontFamily: FB, fontSize: 16, lineHeight: 1.6, color: '#fff', margin: 0, textAlign: 'center', overflowWrap: 'anywhere' }}>
{activeGalleryImage.caption}
</p>
) : null}
</div>
{galleryImages.length > 1 ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setActiveGalleryIndex((activeGalleryIndex ?? 0) === galleryImages.length - 1 ? 0 : (activeGalleryIndex ?? 0) + 1);
}}
aria-label="Nächstes Bild"
style={{ position: 'absolute', right: isMobile ? 12 : 24, top: '50%', transform: 'translateY(-50%)', width: 44, height: 44, border: `1px solid rgba(255,255,255,0.28)`, background: 'rgba(255,255,255,0.08)', color: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer' }}
>
<ChevronRight size={24} />
</button>
) : null}
</div>
) : null}
{/* Back link */}
<div style={{ padding: isMobile ? '32px 24px 48px' : '32px 80px 64px', borderTop: `1px solid ${BORDER}` }}>
<Link
to={fallbackText(backLink.url, preistraegerDetailContent.backLink.url)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', textDecoration: 'none', textTransform: 'uppercase', letterSpacing: '0.06em', transition: 'color 0.15s' }}
onMouseEnter={e => ((e.currentTarget as HTMLElement).style.color = GOLD)}
onMouseLeave={e => ((e.currentTarget as HTMLElement).style.color = NAVY)}
>
<ArrowLeft size={16} /> {fallbackText(backLink.label, preistraegerDetailContent.backLink.label)}
</Link>
</div>
</div>
);
}