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.
This commit is contained in:
syntaxbullet
2026-07-07 14:57:15 +02:00
parent 4167730fc2
commit d2fdf8e3c5
25 changed files with 1139 additions and 595 deletions

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Link } from '@/spa/router';
import { Calendar, MapPin, Download, ArrowRight, FileText } from 'lucide-react';
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';
@@ -75,20 +74,17 @@ function Lines({ text }: { text: string }) {
const fallbackCardImage = (item: PressEventItem | PressNewsItem) => mediaUrl(item.image, fallbackText(item.img, ''))
function NewsCard({ item, idx, isMobile, readMoreLabel, total }: { item: PressNewsItem; idx: number; isMobile: boolean; readMoreLabel: string; total: number }) {
function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: number; isMobile: boolean; total: number }) {
const [hovered, setHovered] = React.useState(false);
return (
<Link
to={item.slug}
<article
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
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',
textDecoration: 'none',
}}
>
{/* image */}
@@ -166,54 +162,8 @@ function NewsCard({ item, idx, isMobile, readMoreLabel, total }: { item: PressNe
>
{item.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>
</div>
</Link>
);
}
function DarkInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
const [f, setF] = React.useState(false);
return (
<input
{...props}
onFocus={(e) => {
setF(true);
props.onFocus?.(e);
}}
onBlur={(e) => {
setF(false);
props.onBlur?.(e);
}}
style={{
width: '100%',
height: 44,
padding: '0 14px',
fontFamily: FF,
fontSize: 18,
color: '#fff',
border: `1px solid ${f ? 'rgba(239,191,4,0.8)' : 'rgba(255,255,255,0.12)'}`,
background: f ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.05)',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
...props.style,
}}
/>
</article>
);
}
@@ -224,7 +174,6 @@ const Press: React.FC = () => {
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 accreditation = React.useMemo(() => ({ ...pressIndexContent.accreditation, ...(cms.accreditation || {}) }), [cms.accreditation]);
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
const cmsEvents = useCmsCollection('events');
const cmsPosts = useCmsCollection('posts');
@@ -248,22 +197,6 @@ const Press: React.FC = () => {
if (cmsPosts.length) return cmsPosts.map((post) => postFromCms(post, newsCollectionFallbackImage, newsSection));
return fallbackArray<PressNewsItem>(newsSection.fallbackItems, pressIndexContent.news.fallbackItems);
}, [cmsPosts, newsCollectionFallbackImage, newsSection]);
const [hoveredEvent, setHoveredEvent] = useState<number | null>(null);
const [dlHovered, setDlHovered] = useState(false);
const [submitHovered, setSubmitHovered] = useState(false);
// Form state
const [medium, setMedium] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = () => {
if (medium.trim() && name.trim() && email.trim()) {
setSubmitted(true);
}
};
return (
<div className="animate-fade-in">
@@ -411,15 +344,11 @@ const Press: React.FC = () => {
{combinedEvents.map((event, idx) => (
<div
key={idx}
onMouseEnter={() => setHoveredEvent(idx)}
onMouseLeave={() => setHoveredEvent(null)}
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '320px 1fr',
borderBottom: '1px solid rgba(3,9,58,0.08)',
background: hoveredEvent === idx ? '#EAE7E6' : 'transparent',
transition: 'background 0.15s',
cursor: 'pointer',
background: 'transparent',
}}
>
{/* Left – image cell */}
@@ -444,9 +373,6 @@ const Press: React.FC = () => {
</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>
<Link to={event.slug} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16, fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: '#101828', textDecoration: 'none' }}>
{fallbackText(eventsSection.detailLabel, pressIndexContent.events.detailLabel)} <ArrowRight size={12} />
</Link>
</div>
</div>
))}
@@ -511,7 +437,7 @@ const Press: React.FC = () => {
{/* 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} readMoreLabel={fallbackText(newsSection.readMoreLabel, pressIndexContent.news.readMoreLabel)} total={combinedNews.length} />
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
))}
</div>
</section>
@@ -519,7 +445,7 @@ const Press: React.FC = () => {
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
<section id="downloads" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 600 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
{/* Left col */}
<div
@@ -529,8 +455,6 @@ const Press: React.FC = () => {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
borderRight: isMobile ? 'none' : '1px solid rgba(3,9,58,0.1)',
borderBottom: isMobile ? '1px solid rgba(3,9,58,0.1)' : 'none',
}}
>
<div
@@ -571,62 +495,12 @@ const Press: React.FC = () => {
color: 'rgba(16,24,40,0.5)',
lineHeight: 1.8,
maxWidth: 380,
marginBottom: 48,
marginBottom: 0,
}}
>
{fallbackText(downloads.description, pressIndexContent.downloads.description)}
</p>
{/* Download button */}
<a
href={mediaUrl(downloads.kitFile, fallbackText(downloads.kitUrl, pressIndexContent.downloads.kitUrl))}
download={fallbackText(downloads.kitDownloadName, pressIndexContent.downloads.kitDownloadName)}
onMouseEnter={() => setDlHovered(true)}
onMouseLeave={() => setDlHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 20,
padding: '24px 28px',
border: `1px solid ${dlHovered ? GOLD : 'rgba(3,9,58,0.12)'}`,
background: '#fff',
cursor: 'pointer',
marginBottom: 20,
transition: 'border-color 0.15s',
textDecoration: 'none',
}}
>
<div
style={{
width: 48,
height: 48,
background: NAVY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Download size={20} color={GOLD} />
</div>
<div>
<div
style={{
fontFamily: FF,
fontSize: 18,
fontWeight: 700,
color: '#101828',
marginBottom: 3,
}}
>
{fallbackText(downloads.kitLabel, pressIndexContent.downloads.kitLabel)}
</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.4)' }}>
{fallbackText(downloads.kitMeta, pressIndexContent.downloads.kitMeta)}
</div>
</div>
</a>
{/* Press contact block */}
<div style={{ borderLeft: `3px solid ${GOLD}`, paddingLeft: 24, marginTop: 32 }}>
<div
@@ -671,204 +545,6 @@ const Press: React.FC = () => {
</div>
</div>
</div>
{/* Right col */}
<div
style={{
background: NAVY,
padding: isMobile ? '48px 24px' : '72px 64px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{submitted ? (
/* Success state */
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
}}
>
<div
style={{
width: 56,
height: 56,
background: GOLD,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 28,
flexShrink: 0,
}}
>
<FileText size={24} color={NAVY} />
</div>
<h3
style={{
fontFamily: FF,
fontSize: 'clamp(1.4rem, 2.2vw, 1.8rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.02em',
margin: '0 0 16px',
}}
>
{fallbackText(accreditation.successHeading, pressIndexContent.accreditation.successHeading)}
</h3>
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.45)',
lineHeight: 1.8,
margin: 0,
}}
>
{fallbackText(accreditation.successMessage, pressIndexContent.accreditation.successMessage)}
</p>
</div>
) : (
<>
<div
style={{
fontFamily: FF,
fontSize: 10,
color: GOLD,
textTransform: 'uppercase',
letterSpacing: '0.32em',
fontWeight: 700,
marginBottom: 20,
}}
>
{fallbackText(accreditation.eyebrow, pressIndexContent.accreditation.eyebrow)}
</div>
<h3
style={{
fontFamily: FF,
fontSize: 'clamp(1.4rem, 2.2vw, 1.8rem)',
fontWeight: 900,
color: '#fff',
textTransform: 'uppercase',
letterSpacing: '-0.02em',
margin: '0 0 16px',
}}
>
<Lines text={fallbackText(accreditation.heading, pressIndexContent.accreditation.heading)} />
</h3>
{/* Gold divider */}
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
<p
style={{
fontFamily: FB,
fontSize: 18,
color: 'rgba(255,255,255,0.45)',
lineHeight: 1.8,
marginBottom: 36,
}}
>
{fallbackText(accreditation.description, pressIndexContent.accreditation.description)}
</p>
{/* Form fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.35)',
}}
>
{fallbackText(accreditation.mediumLabel, pressIndexContent.accreditation.mediumLabel)}
</label>
<DarkInput
type="text"
value={medium}
onChange={(e) => setMedium(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.35)',
}}
>
{fallbackText(accreditation.nameLabel, pressIndexContent.accreditation.nameLabel)}
</label>
<DarkInput
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
style={{
fontFamily: FF,
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: 'rgba(255,255,255,0.35)',
}}
>
{fallbackText(accreditation.emailLabel, pressIndexContent.accreditation.emailLabel)}
</label>
<DarkInput
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
onMouseEnter={() => setSubmitHovered(true)}
onMouseLeave={() => setSubmitHovered(false)}
style={{
background: submitHovered ? '#FFD130' : GOLD,
boxShadow: submitHovered ? '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)' : 'none',
color: '#101828',
border: 'none',
fontFamily: FF,
fontSize: 16,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.12em',
cursor: 'pointer',
padding: '14px 28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
width: '100%',
transition: 'background 0.15s, box-shadow 0.2s',
}}
>
{fallbackText(accreditation.submitLabel, pressIndexContent.accreditation.submitLabel)} <ArrowRight size={13} />
</button>
</div>
</>
)}
</div>
</div>
</section>
</div>