diff --git a/src/collections/Preistraeger.ts b/src/collections/Preistraeger.ts index c8eebf6..9f830e1 100644 --- a/src/collections/Preistraeger.ts +++ b/src/collections/Preistraeger.ts @@ -68,6 +68,11 @@ export const Preistraeger: CollectionConfig = { { name: 'year', type: 'number', + label: 'Jahr (optional)', + admin: { + description: + 'Einträge von 2007 bis 2023 werden gemeinsam im Archiv-Reiter angezeigt. Wenn das genaue Gewinnjahr unbekannt ist, das Feld leer lassen; der Eintrag wird ebenfalls diesem Archiv zugeordnet.', + }, }, { name: 'awardGroup', @@ -77,7 +82,7 @@ export const Preistraeger: CollectionConfig = { options: awardGroupOptions, admin: { description: - 'Steuert die Ausgabe auf der Preisträger-Übersicht. Pro Jahrgang werden 15 Gewinner angezeigt, davon maximal 3 in der Goldene-Bavaria-Section.', + 'Steuert die Ausgabe für einzelne Jahrgänge ab 2024. Im Archiv 2007–2023 werden alle Einträge als einfache Liste angezeigt.', }, }, { @@ -87,7 +92,7 @@ export const Preistraeger: CollectionConfig = { min: 1, admin: { description: - '1 bis 3 sind die besten Gewinner und sollten als Goldene Bavaria markiert werden. Niedrigere Zahlen erscheinen zuerst.', + 'Für einzelne Jahrgänge ab 2024: 1 bis 3 sind die besten Gewinner und sollten als Goldene Bavaria markiert werden. Niedrigere Zahlen erscheinen zuerst.', }, }, { @@ -96,7 +101,7 @@ export const Preistraeger: CollectionConfig = { label: 'Sortierung', defaultValue: 100, admin: { - description: 'Fallback-Sortierung innerhalb eines Jahrgangs, wenn kein Rang gesetzt ist.', + description: 'Sortierung innerhalb eines Jahrgangs beziehungsweise innerhalb des Archivs 2007–2023.', }, }, { diff --git a/src/payload-types.ts b/src/payload-types.ts index 2a18ce0..60f8ac5 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -2495,17 +2495,20 @@ export interface Preistraeger { title: string; image?: (number | null) | Media; category?: string | null; + /** + * Einträge von 2007 bis 2023 werden gemeinsam im Archiv-Reiter angezeigt. Wenn das genaue Gewinnjahr unbekannt ist, das Feld leer lassen; der Eintrag wird ebenfalls diesem Archiv zugeordnet. + */ year?: number | null; /** - * Steuert die Ausgabe auf der Preisträger-Übersicht. Pro Jahrgang werden 15 Gewinner angezeigt, davon maximal 3 in der Goldene-Bavaria-Section. + * Steuert die Ausgabe für einzelne Jahrgänge ab 2024. Im Archiv 2007–2023 werden alle Einträge als einfache Liste angezeigt. */ awardGroup?: ('preistraeger' | 'goldene-bavaria') | null; /** - * 1 bis 3 sind die besten Gewinner und sollten als Goldene Bavaria markiert werden. Niedrigere Zahlen erscheinen zuerst. + * Für einzelne Jahrgänge ab 2024: 1 bis 3 sind die besten Gewinner und sollten als Goldene Bavaria markiert werden. Niedrigere Zahlen erscheinen zuerst. */ winnerRank?: number | null; /** - * Fallback-Sortierung innerhalb eines Jahrgangs, wenn kein Rang gesetzt ist. + * Sortierung innerhalb eines Jahrgangs beziehungsweise innerhalb des Archivs 2007–2023. */ sortOrder?: number | null; showOnIndex?: boolean | null; diff --git a/src/spa/data/winners.ts b/src/spa/data/winners.ts index e42f1cf..6e8113d 100644 --- a/src/spa/data/winners.ts +++ b/src/spa/data/winners.ts @@ -3,7 +3,7 @@ export interface Winner { slug: string; name: string; category: string; - year: number; + year?: number; type: string; awardGroup?: 'preistraeger' | 'goldene-bavaria'; winnerRank?: number; @@ -21,7 +21,9 @@ export interface Winner { hasMedia: boolean; } -const winnerEntries: Omit[] = [ +type StaticWinner = Omit & { year: number }; + +const winnerEntries: StaticWinner[] = [ // ── 2025 ────────────────────────────────────────────────────────────────── { id: '1', slug: 'techvision-muenchen', @@ -326,4 +328,6 @@ export const WINNERS: Winner[] = winnerEntries.map((winner) => { }); export const CATEGORIES = [...new Set(WINNERS.map(w => w.category))].sort(); -export const YEARS = [...new Set(WINNERS.map(w => w.year))].sort((a, b) => b - a); +export const YEARS = [...new Set(WINNERS.map(w => w.year))] + .filter((year): year is number => typeof year === 'number') + .sort((a, b) => b - a); diff --git a/src/spa/pages/Preistraeger.tsx b/src/spa/pages/Preistraeger.tsx index 21d6898..17ad3e1 100644 --- a/src/spa/pages/Preistraeger.tsx +++ b/src/spa/pages/Preistraeger.tsx @@ -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 & { hero?: Partial & { image?: unknown } card?: Partial & { fallbackImage?: unknown } @@ -26,6 +31,7 @@ type PreistraegerIndexCms = Partial & { 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(undefined); - const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear(); + const periods = useMemo(() => { + 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(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 (
@@ -170,23 +211,23 @@ export default function Preistraeger() { {/* Year Tabs */}
- {years.map(year => ( + {periods.map(period => ( ))}
@@ -196,7 +237,7 @@ export default function Preistraeger() {
- {selectedYear} – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)} + {selectedPeriodLabel} – {winnerGroups.displayedWinners.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
@@ -204,6 +245,8 @@ export default function Preistraeger() {
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
+ ) : isArchivePeriod ? ( + ) : ( <> {winnerGroups.goldeneBavariaWinners.length > 0 ? ( @@ -217,27 +260,23 @@ export default function Preistraeger() { {winnerGroups.remainingWinners.length > 0 ? (
- {usesSimpleWinnerGrid ? ( - - ) : ( -
- {winnerGroups.remainingWinners.map(w => ( - - ))} -
- )} +
+ {winnerGroups.remainingWinners.map(w => ( + + ))} +
) : 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 (
- - {winner.year} · {winner.type} - + {winnerMeta ? ( + + {winnerMeta} + + ) : null} {winner.category ? ( 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; +}; const mediaFilename = (media: unknown) => { if (!media || typeof media !== 'object') return undefined; @@ -60,7 +65,7 @@ export default function PreistraegerDetail() { slug: String(cmsWinner.slug || slug), name: String(cmsWinner.title || fallbackWinner.name || preistraegerDetailContent.fallbackWinner.name), category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category), - year: Number(cmsWinner.year || new Date().getFullYear()), + year: optionalNumber(cmsWinner.year), type: String(cmsWinner.awardType || (cmsWinnerIsGoldeneBavaria ? 'Goldene Bavaria' : fallbackWinner.awardType) || preistraegerDetailContent.fallbackWinner.awardType), img: docImageUrl(cmsWinner, placeholderImage), shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''), @@ -134,9 +139,11 @@ export default function PreistraegerDetail() { }}> {winner.category} - - {winner.year} - + {typeof winner.year === 'number' ? ( + + {winner.year} + + ) : null} {winner.type} @@ -196,7 +203,7 @@ export default function PreistraegerDetail() { {[ { label: fallbackText(facts.categoryLabel, preistraegerDetailContent.facts.categoryLabel), value: winner.category, icon: }, { label: fallbackText(facts.awardLabel, preistraegerDetailContent.facts.awardLabel), value: winner.type, icon: }, - { label: fallbackText(facts.yearLabel, preistraegerDetailContent.facts.yearLabel), value: String(winner.year), icon: null }, + ...(typeof winner.year === 'number' ? [{ label: fallbackText(facts.yearLabel, preistraegerDetailContent.facts.yearLabel), value: String(winner.year), icon: null }] : []), { label: fallbackText(facts.locationLabel, preistraegerDetailContent.facts.locationLabel), value: winner.location, icon: }, { label: fallbackText(facts.industryLabel, preistraegerDetailContent.facts.industryLabel), value: winner.industry, icon: }, ].map(row => (