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:
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { Trophy, ArrowRight } from 'lucide-react';
|
||||
import { Trophy, ArrowRight, Medal } from 'lucide-react';
|
||||
import { WINNERS, type Winner } from '@/spa/data/winners';
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
@@ -16,6 +16,8 @@ const BORDER = '#D0D5DD';
|
||||
const GRAY = '#666666';
|
||||
const BG_ALT = '#E4E2E3';
|
||||
const SIMPLE_LIST_YEAR = 2023;
|
||||
const GOLDENE_BAVARIA_GROUP = 'goldene-bavaria';
|
||||
const PREISTRAEGER_GROUP = 'preistraeger';
|
||||
|
||||
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
|
||||
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
|
||||
@@ -23,6 +25,60 @@ type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
|
||||
}
|
||||
|
||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
const optionalNumber = (value: unknown) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
const positiveNumber = (value: unknown, fallback: number) => {
|
||||
const parsed = optionalNumber(value);
|
||||
return parsed && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
};
|
||||
const normalizeAwardGroup = (value: unknown): Winner['awardGroup'] =>
|
||||
value === GOLDENE_BAVARIA_GROUP || value === PREISTRAEGER_GROUP ? value : undefined;
|
||||
|
||||
const isGoldeneBavariaWinner = (winner: Winner) =>
|
||||
winner.awardGroup === GOLDENE_BAVARIA_GROUP || winner.type.toLowerCase().includes('goldene bavaria');
|
||||
|
||||
const sortWinnersForDisplay = (winners: Winner[]) =>
|
||||
winners
|
||||
.map((winner, index) => ({ winner, index }))
|
||||
.sort((a, b) => {
|
||||
const rankDiff = (a.winner.winnerRank ?? Number.POSITIVE_INFINITY) - (b.winner.winnerRank ?? Number.POSITIVE_INFINITY);
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
|
||||
const sortDiff = (a.winner.sortOrder ?? Number.POSITIVE_INFINITY) - (b.winner.sortOrder ?? Number.POSITIVE_INFINITY);
|
||||
if (sortDiff !== 0) return sortDiff;
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ winner }) => winner);
|
||||
|
||||
const splitWinnersForYear = (winners: Winner[], totalWinners: number, goldeneBavariaCount: number) => {
|
||||
const visibleWinners = sortWinnersForDisplay(winners.filter((winner) => winner.showOnIndex !== false));
|
||||
const maxGoldeneBavaria = Math.min(goldeneBavariaCount, totalWinners);
|
||||
const goldeneBavariaWinners = visibleWinners
|
||||
.filter((winner) => isGoldeneBavariaWinner(winner) || (winner.winnerRank || Number.POSITIVE_INFINITY) <= maxGoldeneBavaria)
|
||||
.slice(0, maxGoldeneBavaria);
|
||||
const goldIds = new Set(goldeneBavariaWinners.map((winner) => winner.id));
|
||||
|
||||
for (const winner of visibleWinners) {
|
||||
if (goldeneBavariaWinners.length >= maxGoldeneBavaria) break;
|
||||
if (goldIds.has(winner.id) || winner.awardGroup === PREISTRAEGER_GROUP) continue;
|
||||
|
||||
goldeneBavariaWinners.push(winner);
|
||||
goldIds.add(winner.id);
|
||||
}
|
||||
|
||||
const remainingWinners = visibleWinners
|
||||
.filter((winner) => !goldIds.has(winner.id))
|
||||
.slice(0, Math.max(totalWinners - goldeneBavariaWinners.length, 0));
|
||||
|
||||
return {
|
||||
goldeneBavariaWinners,
|
||||
remainingWinners,
|
||||
displayedWinners: [...goldeneBavariaWinners, ...remainingWinners],
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeWinner = (
|
||||
doc: CmsRouteDoc,
|
||||
@@ -34,7 +90,15 @@ const normalizeWinner = (
|
||||
name: String(doc.title || fallbackWinner.name || preistraegerIndexContent.card.fallbackWinner.name),
|
||||
category: String(doc.category || fallbackWinner.category || preistraegerIndexContent.card.fallbackWinner.category),
|
||||
year: Number(doc.year || new Date().getFullYear()),
|
||||
type: String(doc.awardType || fallbackWinner.awardType || preistraegerIndexContent.card.fallbackWinner.awardType),
|
||||
type: String(
|
||||
doc.awardType ||
|
||||
(normalizeAwardGroup(doc.awardGroup) === GOLDENE_BAVARIA_GROUP ? 'Goldene Bavaria' : fallbackWinner.awardType) ||
|
||||
preistraegerIndexContent.card.fallbackWinner.awardType,
|
||||
),
|
||||
awardGroup: normalizeAwardGroup(doc.awardGroup),
|
||||
winnerRank: optionalNumber(doc.winnerRank),
|
||||
sortOrder: optionalNumber(doc.sortOrder),
|
||||
showOnIndex: doc.showOnIndex !== false,
|
||||
img: docImageUrl(doc, placeholderImage),
|
||||
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
|
||||
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
|
||||
@@ -55,9 +119,17 @@ export default function Preistraeger() {
|
||||
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
|
||||
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
|
||||
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
|
||||
const rules = { ...preistraegerIndexContent.rules, ...(cms.rules || {}) };
|
||||
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
|
||||
const goldeneBavaria = { ...preistraegerIndexContent.goldeneBavaria, ...(cms.goldeneBavaria || {}) };
|
||||
const winnersSection = { ...preistraegerIndexContent.winners, ...(cms.winners || {}) };
|
||||
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
|
||||
const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) };
|
||||
const totalWinners = positiveNumber(rules.totalWinners, preistraegerIndexContent.rules.totalWinners);
|
||||
const goldeneBavariaCount = Math.min(
|
||||
positiveNumber(rules.goldeneBavariaCount, preistraegerIndexContent.rules.goldeneBavariaCount),
|
||||
totalWinners,
|
||||
);
|
||||
const winners = useMemo(() => {
|
||||
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
|
||||
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
|
||||
@@ -68,6 +140,10 @@ export default function Preistraeger() {
|
||||
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
|
||||
|
||||
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
|
||||
const winnerGroups = useMemo(
|
||||
() => splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount),
|
||||
[filtered, goldeneBavariaCount, totalWinners],
|
||||
);
|
||||
const usesSimpleWinnerGrid = selectedYear === SIMPLE_LIST_YEAR;
|
||||
|
||||
return (
|
||||
@@ -120,35 +196,51 @@ export default function Preistraeger() {
|
||||
<div style={{ padding: isMobile ? '12px 24px' : '12px 80px', background: BG_ALT, borderTop: `1px solid ${BORDER}`, borderBottom: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Trophy size={13} style={{ color: GOLD }} fill={GOLD} />
|
||||
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>
|
||||
<strong style={{ color: '#101828' }}>{selectedYear}</strong> – {filtered.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
|
||||
<strong style={{ color: '#101828' }}>{selectedYear}</strong> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
{winnerGroups.displayedWinners.length === 0 ? (
|
||||
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
|
||||
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
|
||||
</div>
|
||||
) : usesSimpleWinnerGrid ? (
|
||||
<SimpleWinnerGrid winners={filtered} />
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
|
||||
gridAutoFlow: isMobile ? 'column' : undefined,
|
||||
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
|
||||
gridAutoColumns: isMobile ? '74vw' : undefined,
|
||||
overflowX: isMobile ? 'auto' : undefined,
|
||||
scrollSnapType: isMobile ? 'x mandatory' : undefined,
|
||||
gap: isMobile ? 8 : 0,
|
||||
padding: isMobile ? '0 16px 8px' : 0,
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
}}>
|
||||
{filtered.map(w => (
|
||||
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{winnerGroups.goldeneBavariaWinners.length > 0 ? (
|
||||
<GoldeneBavariaSection
|
||||
copy={goldeneBavaria}
|
||||
hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)}
|
||||
winners={winnerGroups.goldeneBavariaWinners}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{winnerGroups.remainingWinners.length > 0 ? (
|
||||
<section style={{ background: '#fff' }}>
|
||||
<SectionIntro copy={winnersSection} isMobile={isMobile} />
|
||||
{usesSimpleWinnerGrid ? (
|
||||
<SimpleWinnerGrid winners={winnerGroups.remainingWinners} />
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? undefined : 'repeat(4, 1fr)',
|
||||
gridAutoFlow: isMobile ? 'column' : undefined,
|
||||
gridTemplateRows: isMobile ? 'repeat(2, 1fr)' : undefined,
|
||||
gridAutoColumns: isMobile ? '74vw' : undefined,
|
||||
overflowX: isMobile ? 'auto' : undefined,
|
||||
scrollSnapType: isMobile ? 'x mandatory' : undefined,
|
||||
gap: isMobile ? 8 : 0,
|
||||
padding: isMobile ? '0 16px 8px' : 0,
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
}}>
|
||||
{winnerGroups.remainingWinners.map(w => (
|
||||
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bottom CTA */}
|
||||
@@ -172,6 +264,101 @@ export default function Preistraeger() {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionIntro({ copy, isMobile }: { copy: typeof preistraegerIndexContent.winners; isMobile: boolean }) {
|
||||
return (
|
||||
<div style={{ padding: isMobile ? '40px 24px 22px' : '56px 80px 28px', maxWidth: 900 }}>
|
||||
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
{fallbackText(copy.eyebrow, preistraegerIndexContent.winners.eyebrow)}
|
||||
</span>
|
||||
<h2 style={{ color: NAVY, fontFamily: FF, fontSize: isMobile ? 28 : 42, fontWeight: 900, lineHeight: 1.05, margin: '10px 0 12px', textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
{fallbackText(copy.heading, preistraegerIndexContent.winners.heading)}
|
||||
</h2>
|
||||
<p style={{ color: GRAY, fontFamily: FF, fontSize: 17, lineHeight: 1.6, margin: 0, maxWidth: 680 }}>
|
||||
{fallbackText(copy.description, preistraegerIndexContent.winners.description)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoldeneBavariaSection({
|
||||
copy,
|
||||
hoverLabel,
|
||||
winners,
|
||||
}: {
|
||||
copy: typeof preistraegerIndexContent.goldeneBavaria;
|
||||
hoverLabel: string;
|
||||
winners: Winner[];
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<section style={{ background: NAVY, color: '#fff', borderTop: `1px solid ${BORDER}` }}>
|
||||
<div style={{ padding: isMobile ? '44px 24px 26px' : '64px 80px 34px', maxWidth: 980 }}>
|
||||
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
{fallbackText(copy.eyebrow, preistraegerIndexContent.goldeneBavaria.eyebrow)}
|
||||
</span>
|
||||
<h2 style={{ color: '#fff', fontFamily: FF, fontSize: isMobile ? 30 : 48, fontWeight: 900, lineHeight: 1.02, margin: '10px 0 14px', textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
{fallbackText(copy.heading, preistraegerIndexContent.goldeneBavaria.heading)}
|
||||
</h2>
|
||||
<p style={{ color: 'rgba(255,255,255,0.74)', fontFamily: FF, fontSize: 18, lineHeight: 1.6, margin: 0, maxWidth: 760 }}>
|
||||
{fallbackText(copy.description, preistraegerIndexContent.goldeneBavaria.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
||||
gap: isMobile ? 14 : 16,
|
||||
padding: isMobile ? '0 24px 48px' : '0 80px 72px',
|
||||
}}>
|
||||
{winners.map((winner, index) => (
|
||||
<GoldeneBavariaCard key={winner.id} winner={winner} rank={winner.winnerRank || index + 1} hoverLabel={hoverLabel} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GoldeneBavariaCard({ winner, rank, hoverLabel }: { winner: Winner; rank: number; hoverLabel: string }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/preistraeger/${winner.slug}`}
|
||||
style={{ background: '#fff', color: '#101828', display: 'block', minHeight: isMobile ? 0 : 430, overflow: 'hidden', position: 'relative', textDecoration: 'none' }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ height: isMobile ? 250 : 300, overflow: 'hidden', position: 'relative' }}>
|
||||
<Image unoptimized
|
||||
src={winner.img}
|
||||
alt={winner.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', transition: 'transform 0.5s ease', transform: hovered ? 'scale(1.05)' : 'scale(1)' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.62), transparent 62%)' }} />
|
||||
<div style={{ position: 'absolute', left: 18, bottom: 18, display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: NAVY, padding: '8px 12px', fontFamily: FF, fontSize: 13, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
<Medal size={16} /> #{rank} Goldene Bavaria
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: isMobile ? '20px' : '24px' }}>
|
||||
<span style={{ color: GOLD, fontFamily: FF, fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
{winner.year} · {winner.category}
|
||||
</span>
|
||||
<h3 style={{ color: NAVY, fontFamily: FF, fontSize: isMobile ? 23 : 26, fontWeight: 900, lineHeight: 1.12, margin: '10px 0 10px', overflowWrap: 'anywhere' }}>
|
||||
{winner.name}
|
||||
</h3>
|
||||
<p style={{ color: GRAY, fontFamily: FF, fontSize: 15, lineHeight: 1.55, margin: 0, overflowWrap: 'anywhere' }}>
|
||||
{winner.shortDesc}
|
||||
</p>
|
||||
<span style={{ marginTop: 18, display: 'inline-flex', alignItems: 'center', gap: 7, color: NAVY, fontFamily: FF, fontSize: 14, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0 }}>
|
||||
<Trophy size={14} /> {hoverLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WinnerCard ───────────────────────────────────────────────────────────────
|
||||
|
||||
function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) {
|
||||
|
||||
Reference in New Issue
Block a user