- 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.
505 lines
23 KiB
TypeScript
505 lines
23 KiB
TypeScript
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>
|
||
);
|
||
}
|