feat: add preistraeger placeholder image and related database migrations

- Introduced a new SVG placeholder image for preistraeger without an image.
- Created migration scripts to add download zip and gallery features to the preistraeger table.
- Added site settings for preistraeger placeholder image in the database.
- Implemented a utility function to retrieve the preistraeger placeholder image URL in the SPA.
This commit is contained in:
syntaxbullet
2026-07-07 12:53:00 +02:00
parent 2842caa936
commit 4167730fc2
15 changed files with 10903 additions and 25 deletions

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { Link, useParams, Navigate } from '@/spa/router';
import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight } from 'lucide-react';
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';
@@ -21,18 +22,38 @@ type PreistraegerDetailCms = Partial<typeof preistraegerDetailContent> & {
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 winnerFallbackImage = mediaUrl(cmsDetail.fallbackImage, `/images/${preistraegerDetailContent.fallbackImageFilename}`);
const winner = cmsWinner ? {
id: String(cmsWinner.id),
slug: String(cmsWinner.slug || slug),
@@ -40,7 +61,7 @@ export default function PreistraegerDetail() {
category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category),
year: Number(cmsWinner.year || new Date().getFullYear()),
type: String(cmsWinner.awardType || fallbackWinner.awardType || preistraegerDetailContent.fallbackWinner.awardType),
img: docImageUrl(cmsWinner, winnerFallbackImage),
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),
@@ -61,6 +82,23 @@ export default function PreistraegerDetail() {
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 />;
@@ -187,9 +225,155 @@ export default function PreistraegerDetail() {
>
<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