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

505 lines
23 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, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
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';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent';
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder';
const FF = '"IBM Plex Sans", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
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 }
card?: Partial<typeof preistraegerIndexContent.card> & { fallbackImage?: unknown }
}
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,
placeholderImage: string,
fallbackWinner: typeof preistraegerIndexContent.card.fallbackWinner,
): Winner => ({
id: String(doc.id),
slug: String(doc.slug || ''),
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 ||
(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 || ''),
quote: String(doc.quote || ''),
quotePerson: String(doc.quotePerson || ''),
quoteRole: String(doc.quoteRole || ''),
location: String(doc.location || fallbackWinner.location || preistraegerIndexContent.card.fallbackWinner.location),
industry: String(doc.industry || fallbackWinner.industry || preistraegerIndexContent.card.fallbackWinner.industry),
website: String(doc.website || '#'),
hasMedia: Boolean(doc.hasMedia),
});
export default function Preistraeger() {
const isMobile = useIsMobile();
const placeholderImage = usePreistraegerPlaceholderImage();
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
const cmsPreistraeger = useCmsCollection('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);
return WINNERS;
}, [card, cmsPreistraeger, placeholderImage]);
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
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 (
<div style={{ background: '#fff', minHeight: '100vh' }}>
{/* Hero */}
<div style={{ width: '100%', height: '50vh', overflow: 'hidden', position: 'relative' }}>
<Image unoptimized
src={mediaUrl(hero.image, fallbackText(hero.imageUrl, preistraegerIndexContent.hero.imageUrl))}
alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, preistraegerIndexContent.hero.imageAlt))}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, transparent 40%, rgba(3,9,58,0.82) 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>
{/* Breadcrumb */}
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}>
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
</div>
{/* Year Filter Section */}
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
{/* #93: Headline entfernt Seitenname steht bereits oben im Breadcrumb. */}
{/* Year Tabs */}
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
{years.map(year => (
<button
key={year}
onClick={() => setActiveYear(year)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700,
padding: isMobile ? '12px 20px' : '12px 32px',
flexShrink: 0,
background: 'none', border: 'none', cursor: 'pointer',
color: selectedYear === year ? NAVY : GRAY,
borderBottom: selectedYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
marginBottom: -2,
letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s',
}}
>
{year}
</button>
))}
</div>
</div>
{/* Count bar */}
<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> {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span>
</div>
{winnerGroups.displayedWinners.length === 0 ? (
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</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 */}
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}>
<p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>{fallbackText(cta.text, preistraegerIndexContent.cta.text)}</p>
<Link
to={fallbackText(cta.primaryCta?.url, preistraegerIndexContent.cta.primaryCta.url)}
style={{
fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.12em', color: '#101828', background: GOLD,
padding: '15px 36px', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 8, transition: 'background 0.15s, box-shadow 0.2s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
>
{fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} <ArrowRight size={14} />
</Link>
</div>
</div>
);
}
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 }) {
const [hovered, setHovered] = useState(false);
const isMobile = useIsMobile();
return (
<Link
to={`/preistraeger/${winner.slug}`}
style={{ textDecoration: 'none', display: 'block', position: 'relative', height: isMobile ? 260 : 340, overflow: 'hidden', boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.07)', scrollSnapAlign: isMobile ? 'start' : undefined }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<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.06)' : 'scale(1)' }}
/>
{/* base gradient */}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.15) 55%, transparent 100%)' }} />
{/* hover overlay */}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.22)', opacity: hovered ? 1 : 0, transition: 'opacity 0.25s' }} />
{/* Text */}
<div style={{ position: 'absolute', bottom: 0, left: 0, padding: '18px 22px', width: '100%', boxSizing: 'border-box' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<Trophy size={12} style={{ color: GOLD, flexShrink: 0 }} fill={GOLD} />
<span style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: GOLD, letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{winner.year} · {winner.type}
</span>
</div>
<h3 style={{ fontFamily: FF, fontSize: 18, fontWeight: 700, color: '#fff', margin: 0, lineHeight: 1.3, textShadow: '0 1px 4px rgba(0,0,0,0.5)' }}>
{winner.name}
</h3>
<p style={{ fontFamily: FF, fontSize: 16, color: 'rgba(255,255,255,0.82)', margin: '4px 0 0', lineHeight: 1.3 }}>
{winner.category}
</p>
{/* Pill button */}
<div style={{
marginTop: 12,
display: 'inline-flex', alignItems: 'center', gap: 7,
background: NAVY, color: '#fff',
padding: '9px 18px', borderRadius: 999,
fontSize: 16, fontWeight: 700, fontFamily: FF,
textTransform: 'uppercase', letterSpacing: '0.06em',
opacity: hovered ? 1 : 0,
transform: hovered ? 'translateY(0)' : 'translateY(6px)',
transition: 'opacity 0.25s, transform 0.25s',
}}>
<Trophy size={14} /> {hoverLabel}
</div>
</div>
</Link>
);
}
function SimpleWinnerGrid({ winners }: { winners: Winner[] }) {
const isMobile = useIsMobile();
return (
<div style={{
background: '#F7F7F8',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: isMobile ? 12 : 16,
padding: isMobile ? '24px' : '40px 80px 56px',
}}>
{winners.map(winner => (
<SimpleWinnerCard key={winner.id} winner={winner} />
))}
</div>
);
}
function SimpleWinnerCard({ winner }: { winner: Winner }) {
const isMobile = useIsMobile();
const hasDescription = winner.shortDesc.trim().length > 0;
return (
<article
style={{
background: '#fff',
border: `1px solid ${BORDER}`,
color: '#101828',
display: 'flex',
flexDirection: 'column',
minHeight: isMobile ? 0 : 220,
padding: isMobile ? '20px' : '24px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 18 }}>
<span style={{
color: GOLD,
fontFamily: FF,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}>
{winner.year} · {winner.type}
</span>
{winner.category ? (
<span style={{
border: `1px solid ${BORDER}`,
color: GRAY,
fontFamily: FF,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.2,
padding: '5px 8px',
}}>
{winner.category}
</span>
) : null}
</div>
<h3 style={{
color: NAVY,
fontFamily: FF,
fontSize: isMobile ? 20 : 22,
fontWeight: 700,
lineHeight: 1.18,
margin: 0,
overflowWrap: 'anywhere',
}}>
{winner.name}
</h3>
{hasDescription ? (
<p style={{
color: GRAY,
fontFamily: FF,
fontSize: 15,
lineHeight: 1.55,
margin: '16px 0 0',
overflowWrap: 'anywhere',
}}>
{winner.shortDesc}
</p>
) : null}
</article>
);
}