feat: enhance Preistraeger and PreistraegerDetail components with optional year handling and archive support

This commit is contained in:
syntaxbullet
2026-07-13 14:11:27 +02:00
parent 7668e1464b
commit b4be98fee0
5 changed files with 123 additions and 62 deletions

View File

@@ -15,10 +15,15 @@ const GOLD = '#EFBF04';
const BORDER = '#D0D5DD';
const GRAY = '#666666';
const BG_ALT = '#E4E2E3';
const SIMPLE_LIST_YEAR = 2023;
const ARCHIVE_START_YEAR = 2007;
const ARCHIVE_END_YEAR = 2023;
const ARCHIVE_PERIOD = 'archive-2007-2023' as const;
const ARCHIVE_LABEL = `${ARCHIVE_START_YEAR}–${ARCHIVE_END_YEAR}`;
const GOLDENE_BAVARIA_GROUP = 'goldene-bavaria';
const PREISTRAEGER_GROUP = 'preistraeger';
type WinnerPeriod = number | typeof ARCHIVE_PERIOD;
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
card?: Partial<typeof preistraegerIndexContent.card> & { fallbackImage?: unknown }
@@ -26,6 +31,7 @@ type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const optionalNumber = (value: unknown) => {
if (value === null || value === undefined || value === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
@@ -53,6 +59,18 @@ const sortWinnersForDisplay = (winners: Winner[]) =>
})
.map(({ winner }) => winner);
const isArchiveWinner = (winner: Winner) =>
winner.year === undefined || (winner.year >= ARCHIVE_START_YEAR && winner.year <= ARCHIVE_END_YEAR);
const sortArchiveWinners = (winners: Winner[]) =>
winners
.map((winner, index) => ({ winner, index }))
.sort((a, b) => {
const sortDiff = (a.winner.sortOrder ?? Number.POSITIVE_INFINITY) - (b.winner.sortOrder ?? Number.POSITIVE_INFINITY);
return sortDiff !== 0 ? sortDiff : 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);
@@ -89,7 +107,7 @@ const normalizeWinner = (
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()),
year: optionalNumber(doc.year),
type: String(
doc.awardType ||
(normalizeAwardGroup(doc.awardGroup) === GOLDENE_BAVARIA_GROUP ? 'Goldene Bavaria' : fallbackWinner.awardType) ||
@@ -135,16 +153,39 @@ export default function Preistraeger() {
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 periods = useMemo<WinnerPeriod[]>(() => {
const annualYears = [...new Set(
winners
.map((winner) => winner.year)
.filter((year): year is number => typeof year === 'number' && year > ARCHIVE_END_YEAR),
)].sort((a, b) => b - a);
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
const winnerGroups = useMemo(
() => splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount),
[filtered, goldeneBavariaCount, totalWinners],
return winners.some(isArchiveWinner) ? [...annualYears, ARCHIVE_PERIOD] : annualYears;
}, [winners]);
const [activePeriod, setActivePeriod] = useState<WinnerPeriod | undefined>(undefined);
const selectedPeriod = activePeriod ?? periods[0] ?? ARCHIVE_PERIOD;
const isArchivePeriod = selectedPeriod === ARCHIVE_PERIOD;
const selectedPeriodLabel = isArchivePeriod ? ARCHIVE_LABEL : String(selectedPeriod);
const filtered = useMemo(
() => winners.filter((winner) => isArchivePeriod ? isArchiveWinner(winner) : winner.year === selectedPeriod),
[isArchivePeriod, selectedPeriod, winners],
);
const winnerGroups = useMemo(
() => {
if (isArchivePeriod) {
const archiveWinners = sortArchiveWinners(filtered.filter((winner) => winner.showOnIndex !== false));
return {
goldeneBavariaWinners: [],
remainingWinners: archiveWinners,
displayedWinners: archiveWinners,
};
}
return splitWinnersForYear(filtered, totalWinners, goldeneBavariaCount);
},
[filtered, goldeneBavariaCount, isArchivePeriod, totalWinners],
);
const usesSimpleWinnerGrid = selectedYear === SIMPLE_LIST_YEAR;
return (
<div style={{ background: '#fff', minHeight: '100vh' }}>
@@ -170,23 +211,23 @@ export default function Preistraeger() {
{/* 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 => (
{periods.map(period => (
<button
key={year}
onClick={() => setActiveYear(year)}
key={period}
onClick={() => setActivePeriod(period)}
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',
color: selectedPeriod === period ? NAVY : GRAY,
borderBottom: selectedPeriod === period ? `2px solid ${GOLD}` : '2px solid transparent',
marginBottom: -2,
letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s',
}}
>
{year}
{period === ARCHIVE_PERIOD ? ARCHIVE_LABEL : period}
</button>
))}
</div>
@@ -196,7 +237,7 @@ 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> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
<strong style={{ color: '#101828' }}>{selectedPeriodLabel}</strong> – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
</span>
</div>
@@ -204,6 +245,8 @@ export default function Preistraeger() {
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
</div>
) : isArchivePeriod ? (
<SimpleWinnerGrid winners={winnerGroups.displayedWinners} />
) : (
<>
{winnerGroups.goldeneBavariaWinners.length > 0 ? (
@@ -217,27 +260,23 @@ export default function Preistraeger() {
{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>
)}
<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}
</>
@@ -436,6 +475,7 @@ function SimpleWinnerGrid({ winners }: { winners: Winner[] }) {
function SimpleWinnerCard({ winner }: { winner: Winner }) {
const isMobile = useIsMobile();
const hasDescription = winner.shortDesc.trim().length > 0;
const winnerMeta = [winner.year, winner.type].filter(Boolean).join(' · ');
return (
<article
@@ -450,16 +490,18 @@ function SimpleWinnerCard({ winner }: { winner: Winner }) {
}}
>
<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>
{winnerMeta ? (
<span style={{
color: GOLD,
fontFamily: FF,
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}>
{winnerMeta}
</span>
) : null}
{winner.category ? (
<span style={{
border: `1px solid ${BORDER}`,