From 4a751317b0da7c47e7c2182c61c326924ba7b03f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 22 Jun 2026 12:40:37 +0200 Subject: [PATCH] feat: manage preistraeger fallback media via payload --- .../Pages/preistraegerIndexFields.ts | 5 ++++- src/collections/Preistraeger.ts | 9 ++++++++ src/payload-types.ts | 10 +++++++++ src/scripts/migrate-spa-pages.ts | 15 ++++++++++--- .../preload-preistraeger-detail-cms.ts | 9 ++++++-- .../preload-preistraeger-index-page-cms.ts | 22 +++++++++++++++++-- src/spa/pages/Preistraeger.tsx | 15 ++++++++----- src/spa/pages/PreistraegerDetail.tsx | 13 ++++++----- src/spa/preistraegerDetailContent.ts | 1 + src/spa/preistraegerIndexContent.ts | 1 + 10 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/collections/Pages/preistraegerIndexFields.ts b/src/collections/Pages/preistraegerIndexFields.ts index 82b4eb6..5c8ba8e 100644 --- a/src/collections/Pages/preistraegerIndexFields.ts +++ b/src/collections/Pages/preistraegerIndexFields.ts @@ -97,7 +97,10 @@ export const preistraegerIndexFields: Field[] = [ label: '06 · Winner cards', type: 'group', admin: sectionAdmin('Hover label shown on winner cards.'), - fields: [text('hoverLabel', 'Hover label', preistraegerIndexContent.card.hoverLabel)], + fields: [ + uploadField('fallbackImage', 'Fallback card image', `Current frontend fallback image: /images/${preistraegerIndexContent.card.fallbackImageFilename}`), + text('hoverLabel', 'Hover label', preistraegerIndexContent.card.hoverLabel), + ], }, { name: 'cta', diff --git a/src/collections/Preistraeger.ts b/src/collections/Preistraeger.ts index 168e4d6..739db9f 100644 --- a/src/collections/Preistraeger.ts +++ b/src/collections/Preistraeger.ts @@ -120,6 +120,15 @@ export const Preistraeger: CollectionConfig = { 'Shared labels, explanatory copy, CTAs, and supporting media for the Preisträger detail route.', }, fields: [ + { + name: 'fallbackImage', + type: 'upload', + relationTo: 'media', + label: 'Fallback hero image', + admin: { + description: `Current frontend fallback image: /images/${preistraegerDetailContent.fallbackImageFilename}`, + }, + }, { name: 'breadcrumb', label: 'Breadcrumb', diff --git a/src/payload-types.ts b/src/payload-types.ts index 88643c1..7c911b2 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1458,6 +1458,10 @@ export interface Page { * Hover label shown on winner cards. */ card?: { + /** + * Current frontend fallback image: /images/gala-saal-overview.jpg + */ + fallbackImage?: (number | null) | Media; hoverLabel?: string | null; }; /** @@ -2760,6 +2764,10 @@ export interface Preistraeger { * Shared labels, explanatory copy, CTAs, and supporting media for the Preisträger detail route. */ detailPage?: { + /** + * Current frontend fallback image: /images/gala-saal-overview.jpg + */ + fallbackImage?: (number | null) | Media; breadcrumb?: { url?: string | null; label?: string | null; @@ -4306,6 +4314,7 @@ export interface PagesSelect { card?: | T | { + fallbackImage?: T; hoverLabel?: T; }; cta?: @@ -5183,6 +5192,7 @@ export interface PreistraegerSelect { detailPage?: | T | { + fallbackImage?: T; breadcrumb?: | T | { diff --git a/src/scripts/migrate-spa-pages.ts b/src/scripts/migrate-spa-pages.ts index f2b4329..e89be09 100644 --- a/src/scripts/migrate-spa-pages.ts +++ b/src/scripts/migrate-spa-pages.ts @@ -119,6 +119,7 @@ function pageData(page: SpaPageRegistryEntry) { function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { const article = articles.find((item) => item.slug === targetSlug(page)) + const { fallbackImageFilename: _fallbackImageFilename, ...detailContent } = blogDetailContent return { ...baseData(page), heroImage: mediaID(mediaMap, article?.img), @@ -132,7 +133,8 @@ function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { tags: article?.tags?.map((tag) => ({ tag })) || [], relatedSlugs: article?.relatedSlugs?.map((slug) => ({ slug })) || [], detailPage: { - ...blogDetailContent, + ...detailContent, + fallbackImage: mediaID(mediaMap, `/${blogDetailContent.fallbackImageFilename}`), metaCopy: blogDetailContent.meta, }, content: emptyRichText( @@ -147,6 +149,7 @@ function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { const event = events.find((item) => item.slug === targetSlug(page)) + const { fallbackImageFilename: _fallbackImageFilename, ...detailContent } = eventDetailContent return { ...baseData(page), subtitle: event?.subtitle, @@ -162,7 +165,8 @@ function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { eckdaten: event?.eckdaten || [], updates: event?.updates?.map(({ id: _id, ...update }) => update) || [], detailPage: { - ...eventDetailContent, + ...detailContent, + fallbackImage: mediaID(mediaMap, `/${eventDetailContent.fallbackImageFilename}`), updatesCopy: eventDetailContent.updates, }, } @@ -170,6 +174,7 @@ function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { function preistraegerData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { const winner = WINNERS.find((item) => item.slug === targetSlug(page)) + const { fallbackImageFilename: _fallbackImageFilename, ...detailContent } = preistraegerDetailContent const { imageFilename: _sideImageFilename, ...sideImage } = preistraegerDetailContent.sideImage return { ...baseData(page), @@ -188,7 +193,8 @@ function preistraegerData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { website: winner?.website, hasMedia: winner?.hasMedia, detailPage: { - ...preistraegerDetailContent, + ...detailContent, + fallbackImage: mediaID(mediaMap, `/${preistraegerDetailContent.fallbackImageFilename}`), sideImage: { ...sideImage, image: mediaID(mediaMap, `/${preistraegerDetailContent.sideImage.imageFilename}`), @@ -284,6 +290,9 @@ async function upsertPage(payload: Payload, page: SpaPageRegistryEntry, mediaMap async function loadMediaMap(payload: Payload): Promise { const sources = new Set([ ...WINNERS.map((winner) => winner.img), + `/${blogDetailContent.fallbackImageFilename}`, + `/${eventDetailContent.fallbackImageFilename}`, + `/${preistraegerDetailContent.fallbackImageFilename}`, `/${preistraegerDetailContent.sideImage.imageFilename}`, ...events.map((event) => event.img), ...articles.map((article) => article.img), diff --git a/src/scripts/preload-preistraeger-detail-cms.ts b/src/scripts/preload-preistraeger-detail-cms.ts index 42e2efc..fd6db1b 100644 --- a/src/scripts/preload-preistraeger-detail-cms.ts +++ b/src/scripts/preload-preistraeger-detail-cms.ts @@ -17,7 +17,11 @@ const mediaByFilename = async (payload: Payload, filename: string) => { async function main() { const payload = await getPayload({ config }) - const sideImage = await mediaByFilename(payload, preistraegerDetailContent.sideImage.imageFilename) + const [fallbackImage, sideImage] = await Promise.all([ + mediaByFilename(payload, preistraegerDetailContent.fallbackImageFilename), + mediaByFilename(payload, preistraegerDetailContent.sideImage.imageFilename), + ]) + const { fallbackImageFilename: _fallbackImageFilename, ...detailContent } = preistraegerDetailContent const { imageFilename: _sideImageFilename, ...sideImageContent } = preistraegerDetailContent.sideImage const winners = await payload.find({ @@ -37,7 +41,8 @@ async function main() { context: { disableRevalidate: true }, data: { detailPage: { - ...preistraegerDetailContent, + ...detailContent, + fallbackImage, sideImage: { ...sideImageContent, image: sideImage, diff --git a/src/scripts/preload-preistraeger-index-page-cms.ts b/src/scripts/preload-preistraeger-index-page-cms.ts index 70887c1..9371510 100644 --- a/src/scripts/preload-preistraeger-index-page-cms.ts +++ b/src/scripts/preload-preistraeger-index-page-cms.ts @@ -1,12 +1,24 @@ import 'dotenv/config' import config from '@payload-config' -import { getPayload } from 'payload' +import { getPayload, type Payload } from 'payload' import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent' +const mediaByFilename = async (payload: Payload, filename: string) => { + const result = await payload.find({ + collection: 'media', + limit: 1, + pagination: false, + where: { filename: { equals: filename } }, + }) + return result.docs[0]?.id +} + async function main() { const payload = await getPayload({ config }) + const fallbackImage = await mediaByFilename(payload, preistraegerIndexContent.card.fallbackImageFilename) + const { fallbackImageFilename: _cardFallbackImageFilename, ...cardContent } = preistraegerIndexContent.card const pageResult = await payload.find({ collection: 'pages', @@ -28,7 +40,13 @@ async function main() { overrideAccess: true, context: { disableRevalidate: true }, data: { - preistraegerIndex: preistraegerIndexContent, + preistraegerIndex: { + ...preistraegerIndexContent, + card: { + ...cardContent, + fallbackImage, + }, + }, } as never, }) diff --git a/src/spa/pages/Preistraeger.tsx b/src/spa/pages/Preistraeger.tsx index 5c78ce5..e3596a6 100644 --- a/src/spa/pages/Preistraeger.tsx +++ b/src/spa/pages/Preistraeger.tsx @@ -17,18 +17,19 @@ const BG_ALT = '#E4E2E3'; type PreistraegerIndexCms = Partial & { hero?: Partial & { image?: unknown } + card?: Partial & { fallbackImage?: unknown } } const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback; -const normalizeWinner = (doc: CmsRouteDoc): Winner => ({ +const normalizeWinner = (doc: CmsRouteDoc, fallbackImage: string): Winner => ({ id: String(doc.id), slug: String(doc.slug || ''), name: String(doc.title || 'Preisträger'), category: String(doc.category || 'Preisträger'), year: Number(doc.year || new Date().getFullYear()), type: String(doc.awardType || 'Auszeichnung'), - img: docImageUrl(doc, '/images/gala-saal-overview.jpg'), + img: docImageUrl(doc, fallbackImage), shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''), longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''), quote: String(doc.quote || ''), @@ -52,12 +53,16 @@ export default function Preistraeger() { const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) }; const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) }; const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) }; - const card = { ...preistraegerIndexContent.card, ...(cms.card || {}) }; + const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]); const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) }; + const cardFallbackImage = useMemo( + () => mediaUrl(card.fallbackImage, `/images/${preistraegerIndexContent.card.fallbackImageFilename}`), + [card], + ); const winners = useMemo(() => { - if (cmsPreistraeger.length) return cmsPreistraeger.map(normalizeWinner).filter((winner) => winner.slug); + if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, cardFallbackImage)).filter((winner) => winner.slug); return WINNERS; - }, [cmsPreistraeger]); + }, [cardFallbackImage, cmsPreistraeger]); const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]); const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]); const [activeYear, setActiveYear] = useState(undefined); diff --git a/src/spa/pages/PreistraegerDetail.tsx b/src/spa/pages/PreistraegerDetail.tsx index c8a1650..15fcb8e 100644 --- a/src/spa/pages/PreistraegerDetail.tsx +++ b/src/spa/pages/PreistraegerDetail.tsx @@ -17,6 +17,7 @@ const GRAY = '#666666'; const BG_ALT = '#E4E2E3'; type PreistraegerDetailCms = Partial & { + fallbackImage?: unknown sideImage?: Partial & { image?: unknown } } @@ -26,6 +27,11 @@ export default function PreistraegerDetail() { const { slug } = useParams<{ slug: string }>(); const route = useCmsRoute(); const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined; + const cmsDetail = { + ...preistraegerDetailContent, + ...((cmsWinner?.detailPage || {}) as PreistraegerDetailCms), + } as PreistraegerDetailCms; + const winnerFallbackImage = mediaUrl(cmsDetail.fallbackImage, `/images/${preistraegerDetailContent.fallbackImageFilename}`); const winner = cmsWinner ? { id: String(cmsWinner.id), slug: String(cmsWinner.slug || slug), @@ -33,7 +39,7 @@ export default function PreistraegerDetail() { category: String(cmsWinner.category || 'Preisträger'), year: Number(cmsWinner.year || new Date().getFullYear()), type: String(cmsWinner.awardType || 'Auszeichnung'), - img: docImageUrl(cmsWinner, '/images/gala-saal-overview.jpg'), + img: docImageUrl(cmsWinner, winnerFallbackImage), shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''), longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || 'Dieser Preisträger wird aus Payload CMS geladen.'), quote: String(cmsWinner.quote || 'Diese Erfolgsgeschichte wird aktuell in Payload CMS gepflegt.'), @@ -47,10 +53,7 @@ export default function PreistraegerDetail() { } : WINNERS.find(w => w.slug === slug); const isMobile = useIsMobile(); - const detail = { - ...preistraegerDetailContent, - ...((winner && 'detailPage' in winner ? winner.detailPage : {}) || {}), - } as PreistraegerDetailCms; + const detail = (winner && 'detailPage' in winner ? { ...preistraegerDetailContent, ...(winner.detailPage || {}) } : cmsDetail) as PreistraegerDetailCms; const breadcrumb = { ...preistraegerDetailContent.breadcrumb, ...(detail.breadcrumb || {}) }; const sections = { ...preistraegerDetailContent.sections, ...(detail.sections || {}) }; const sideImage = { ...preistraegerDetailContent.sideImage, ...(detail.sideImage || {}) }; diff --git a/src/spa/preistraegerDetailContent.ts b/src/spa/preistraegerDetailContent.ts index 326eaec..27219d2 100644 --- a/src/spa/preistraegerDetailContent.ts +++ b/src/spa/preistraegerDetailContent.ts @@ -1,4 +1,5 @@ export const preistraegerDetailContent = { + fallbackImageFilename: 'gala-saal-overview.jpg', notFoundRedirect: '/preistraeger', breadcrumb: { url: '/preistraeger', diff --git a/src/spa/preistraegerIndexContent.ts b/src/spa/preistraegerIndexContent.ts index 876b30b..1375e36 100644 --- a/src/spa/preistraegerIndexContent.ts +++ b/src/spa/preistraegerIndexContent.ts @@ -21,6 +21,7 @@ export const preistraegerIndexContent = { message: 'Keine Preisträger für diese Auswahl.', }, card: { + fallbackImageFilename: 'gala-saal-overview.jpg', hoverLabel: 'Mehr erfahren', }, cta: {