diff --git a/package.json b/package.json
index 4f62c76..553b4c1 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint . --fix",
+ "migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
diff --git a/src/SpaMedia/config.ts b/src/SpaMedia/config.ts
new file mode 100644
index 0000000..3878c35
--- /dev/null
+++ b/src/SpaMedia/config.ts
@@ -0,0 +1,44 @@
+import type { GlobalConfig } from 'payload'
+
+import { defaultSpaMediaSources } from '@/spa/cmsMedia'
+
+export const SpaMedia: GlobalConfig = {
+ slug: 'spa-media',
+ label: 'SPA Media',
+ access: {
+ read: () => true,
+ },
+ admin: {
+ description: 'Central media map for legacy /spa images and videos. The migration script stores each original URL/path here and points it to a Payload media entry.',
+ },
+ fields: [
+ {
+ name: 'assets',
+ type: 'array',
+ labels: {
+ singular: 'Asset',
+ plural: 'Assets',
+ },
+ defaultValue: defaultSpaMediaSources.map((source) => ({ source })),
+ fields: [
+ {
+ name: 'source',
+ type: 'text',
+ required: true,
+ admin: {
+ description: 'Original /spa image/video path or external URL, e.g. /images/gala-dinner.jpg.',
+ },
+ },
+ {
+ name: 'media',
+ type: 'upload',
+ relationTo: 'media',
+ required: true,
+ },
+ ],
+ admin: {
+ initCollapsed: true,
+ },
+ },
+ ],
+}
diff --git a/src/app/(frontend)/[[...slug]]/page.tsx b/src/app/(frontend)/[[...slug]]/page.tsx
index eabc48a..638baba 100644
--- a/src/app/(frontend)/[[...slug]]/page.tsx
+++ b/src/app/(frontend)/[[...slug]]/page.tsx
@@ -3,6 +3,7 @@ import { getPayload } from 'payload'
import NextApp from '@/spa/NextApp'
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
+import { normalizeSpaMediaMap } from '@/spa/cmsMedia'
export const dynamic = 'force-dynamic'
@@ -14,10 +15,18 @@ export default async function Page({ params }: Args) {
const { slug = [] } = await params
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
const payload = await getPayload({ config: configPromise })
- const [header, footer] = await Promise.all([
+ const [header, footer, spaMedia] = await Promise.all([
payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined),
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
+ payload.findGlobal({ slug: 'spa-media', depth: 1 }).catch(() => undefined),
])
- return
+ return (
+
+ )
}
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index c5ecaeb..2e7d273 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -30,51 +30,33 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } f
/** @type import('payload').ImportMap */
export const importMap = {
- '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
- RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
- '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
- RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
- '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
- LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
- '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
- InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
- FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#HeadingFeatureClient':
- HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
- ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
- UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#BoldFeatureClient':
- BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#ItalicFeatureClient':
- ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#LinkFeatureClient':
- LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/plugin-seo/client#OverviewComponent':
- OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
- '@payloadcms/plugin-seo/client#MetaTitleComponent':
- MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
- '@payloadcms/plugin-seo/client#MetaImageComponent':
- MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
- '@payloadcms/plugin-seo/client#MetaDescriptionComponent':
- MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
- '@payloadcms/plugin-seo/client#PreviewComponent':
- PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
- '@payloadcms/next/client#SlugField': SlugField_2b8867833a34864a02ddf429b0728a40,
- '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
- HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/richtext-lexical/client#BlocksFeatureClient':
- BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
- '@payloadcms/next/rsc#FolderTableCell': FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1,
- '@payloadcms/next/rsc#FolderField': FolderField_f9c02e79a4aed9a3924487c0cd4cafb1,
- '@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
- '@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
- '@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40,
- '@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
- '@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
- '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6,
- '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e,
- '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
+ "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
+ "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
+ "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
+ "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
+ "@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
+ "@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
+ "@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
+ "@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
+ "@payloadcms/next/client#SlugField": SlugField_2b8867833a34864a02ddf429b0728a40,
+ "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+ "@payloadcms/next/rsc#FolderTableCell": FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1,
+ "@payloadcms/next/rsc#FolderField": FolderField_f9c02e79a4aed9a3924487c0cd4cafb1,
+ "@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
+ "@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
+ "@payloadcms/next/client#FolderTypeField": FolderTypeField_2b8867833a34864a02ddf429b0728a40,
+ "@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
+ "@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
+ "@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6,
+ "@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e,
+ "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
diff --git a/src/payload-types.ts b/src/payload-types.ts
index 6c3fca0..d6ca460 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -112,10 +112,12 @@ export interface Config {
globals: {
header: Header;
footer: Footer;
+ 'spa-media': SpaMedia;
};
globalsSelect: {
header: HeaderSelect | HeaderSelect;
footer: FooterSelect | FooterSelect;
+ 'spa-media': SpaMediaSelect | SpaMediaSelect;
};
locale: null;
widgets: {
@@ -1756,6 +1758,27 @@ export interface Footer {
updatedAt?: string | null;
createdAt?: string | null;
}
+/**
+ * Central media map for legacy /spa images and videos. The migration script stores each original URL/path here and points it to a Payload media entry.
+ *
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "spa-media".
+ */
+export interface SpaMedia {
+ id: number;
+ assets?:
+ | {
+ /**
+ * Original /spa image/video path or external URL, e.g. /images/gala-dinner.jpg.
+ */
+ source: string;
+ media: number | Media;
+ id?: string | null;
+ }[]
+ | null;
+ updatedAt?: string | null;
+ createdAt?: string | null;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "header_select".
@@ -1874,6 +1897,22 @@ export interface FooterSelect {
createdAt?: T;
globalType?: T;
}
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "spa-media_select".
+ */
+export interface SpaMediaSelect {
+ assets?:
+ | T
+ | {
+ source?: T;
+ media?: T;
+ id?: T;
+ };
+ updatedAt?: T;
+ createdAt?: T;
+ globalType?: T;
+}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
diff --git a/src/payload.config.ts b/src/payload.config.ts
index 66fddd9..23e76f7 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -11,6 +11,7 @@ import { Posts } from './collections/Posts'
import { Users } from './collections/Users'
import { Footer } from './Footer/config'
import { Header } from './Header/config'
+import { SpaMedia } from './SpaMedia/config'
import { plugins } from './plugins'
import { defaultLexical } from '@/fields/defaultLexical'
import { getServerSideURL } from './utilities/getURL'
@@ -64,7 +65,7 @@ export default buildConfig({
}),
collections: [Pages, Posts, Media, Categories, Users],
cors: [getServerSideURL()].filter(Boolean),
- globals: [Header, Footer],
+ globals: [Header, Footer, SpaMedia],
plugins,
secret: process.env.PAYLOAD_SECRET,
sharp,
diff --git a/src/scripts/migrate-spa-media.ts b/src/scripts/migrate-spa-media.ts
new file mode 100644
index 0000000..9b167e3
--- /dev/null
+++ b/src/scripts/migrate-spa-media.ts
@@ -0,0 +1,208 @@
+import config from '@payload-config'
+import crypto from 'crypto'
+import 'dotenv/config'
+import fs from 'fs/promises'
+import path from 'path'
+import { getPayload, type File } from 'payload'
+
+const root = process.cwd()
+const spaDir = path.join(root, 'src/spa')
+const publicDir = path.join(root, 'public')
+
+const mediaExtensions = new Set([
+ '.avif',
+ '.gif',
+ '.jpeg',
+ '.jpg',
+ '.mov',
+ '.mp4',
+ '.png',
+ '.svg',
+ '.webm',
+ '.webp',
+])
+
+const mimeByExt: Record = {
+ '.avif': 'image/avif',
+ '.gif': 'image/gif',
+ '.jpeg': 'image/jpeg',
+ '.jpg': 'image/jpeg',
+ '.mov': 'video/quicktime',
+ '.mp4': 'video/mp4',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.webm': 'video/webm',
+ '.webp': 'image/webp',
+}
+
+async function walk(dir: string): Promise {
+ const entries = await fs.readdir(dir, { withFileTypes: true })
+ const files = await Promise.all(
+ entries.map((entry) => {
+ const full = path.join(dir, entry.name)
+ return entry.isDirectory() ? walk(full) : Promise.resolve([full])
+ }),
+ )
+ return files.flat()
+}
+
+function isMediaSource(value: string) {
+ try {
+ const parsed = value.startsWith('http') ? new URL(value) : undefined
+ const pathname = parsed?.pathname || value.split('?')[0]
+ return mediaExtensions.has(path.extname(pathname).toLowerCase()) || value.includes('images.unsplash.com')
+ } catch {
+ return false
+ }
+}
+
+async function findSpaSources() {
+ const files = (await walk(spaDir)).filter((file) => /\.(tsx?|jsx?)$/.test(file))
+ const sources = new Set()
+ const quotedUrl = /["'`]((?:https?:\/\/images\.unsplash\.com\/[^"'`\s)]+)|(?:\/(?:images\/)?[^"'`\s)]+\.(?:avif|gif|jpe?g|mov|mp4|png|svg|webm|webp)(?:\?[^"'`\s)]*)?))["'`]/gi
+ const cssUrl = /url\((['"]?)(.*?)\1\)/g
+
+ for (const file of files) {
+ const content = await fs.readFile(file, 'utf8')
+ for (const match of content.matchAll(quotedUrl)) {
+ if (isMediaSource(match[1])) sources.add(match[1])
+ }
+ for (const match of content.matchAll(cssUrl)) {
+ if (isMediaSource(match[2])) sources.add(match[2])
+ }
+ }
+
+ return sources
+}
+
+async function findPublicMedia() {
+ const sources = new Set()
+ const files = await walk(publicDir)
+ for (const file of files) {
+ const relative = path.relative(publicDir, file).split(path.sep).join('/')
+ if (relative.startsWith('media/')) continue
+
+ const ext = path.extname(file).toLowerCase()
+ if (!mediaExtensions.has(ext)) continue
+ sources.add(`/${relative}`)
+ }
+ return sources
+}
+
+function safeRemoteName(url: string) {
+ const parsed = new URL(url)
+ const ext = path.extname(parsed.pathname) || '.jpg'
+ const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 10)
+ return `${parsed.hostname.replace(/[^a-z0-9]+/gi, '-')}-${hash}${ext}`.toLowerCase()
+}
+
+async function fileForSource(source: string): Promise {
+ if (source.startsWith('http')) {
+ const res = await fetch(source)
+ if (!res.ok) throw new Error(`Failed to fetch ${source}: ${res.status}`)
+ const data = Buffer.from(await res.arrayBuffer())
+ const name = safeRemoteName(source)
+ return {
+ name,
+ data,
+ mimetype: res.headers.get('content-type')?.split(';')[0] || mimeByExt[path.extname(name)] || 'application/octet-stream',
+ size: data.byteLength,
+ }
+ }
+
+ const cleanSource = source.split('?')[0]
+ const fullPath = path.join(publicDir, cleanSource.replace(/^\//, ''))
+ const data = await fs.readFile(fullPath)
+ const ext = path.extname(fullPath).toLowerCase()
+ return {
+ name: path.basename(fullPath),
+ data,
+ mimetype: mimeByExt[ext] || 'application/octet-stream',
+ size: data.byteLength,
+ }
+}
+
+function altForSource(source: string) {
+ const basename = source.startsWith('http') ? safeRemoteName(source) : path.basename(source.split('?')[0])
+ return basename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ')
+}
+
+async function findExistingMedia(payload: any, filename: string) {
+ const existing = await payload.find({
+ collection: 'media',
+ depth: 0,
+ limit: 1,
+ where: { filename: { equals: filename } },
+ })
+ return existing.docs[0]
+}
+
+async function main() {
+ const payload = await getPayload({ config })
+ const sources = new Set([...(await findPublicMedia()), ...(await findSpaSources())])
+ const sortedSources = [...sources].sort((a, b) => a.localeCompare(b))
+ const assets: { source: string; media: number | string }[] = []
+
+ payload.logger.info(`Migrating ${sortedSources.length} SPA media assets into Payload...`)
+
+ for (const source of sortedSources) {
+ const file = await fileForSource(source)
+ let doc = await findExistingMedia(payload, file.name)
+
+ if (!doc) {
+ doc = await payload.create({
+ collection: 'media',
+ data: { alt: altForSource(source) },
+ file,
+ })
+ payload.logger.info(`created media: ${source} -> ${doc.filename}`)
+ } else {
+ payload.logger.info(`reused media: ${source} -> ${doc.filename}`)
+ }
+
+ assets.push({ source, media: doc.id })
+ }
+
+ const bySource = new Map(assets.map((asset) => [asset.source, asset]))
+
+ await payload.updateGlobal({
+ slug: 'spa-media' as any,
+ data: { assets: [...bySource.values()] } as any,
+ context: { disableRevalidate: true },
+ })
+
+ const assetId = (source: string) => bySource.get(source)?.media
+ await Promise.all([
+ assetId('/bmp-logo.png')
+ ? payload.updateGlobal({
+ slug: 'header',
+ data: { logo: assetId('/bmp-logo.png') } as any,
+ context: { disableRevalidate: true },
+ })
+ : Promise.resolve(),
+ assetId('/bmp-logo.png')
+ ? payload.updateGlobal({
+ slug: 'footer',
+ data: {
+ logo: assetId('/bmp-logo.png'),
+ partnerLogos: [
+ { alt: 'EWIF – Europäisches Wirtschaftsforum', image: assetId('/images/ewif-logo.png') },
+ {
+ alt: 'Hochschule für angewandtes Management · Gipfeldialog Altaussee',
+ image: assetId('/images/partner-logos.png'),
+ },
+ ].filter((item) => item.image),
+ } as any,
+ context: { disableRevalidate: true },
+ })
+ : Promise.resolve(),
+ ])
+
+ payload.logger.info('SPA media migration complete.')
+ process.exit(0)
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})
diff --git a/src/spa/NextApp.tsx b/src/spa/NextApp.tsx
index 6f42762..1245aa5 100644
--- a/src/spa/NextApp.tsx
+++ b/src/spa/NextApp.tsx
@@ -2,9 +2,11 @@
import React, { useEffect, useState } from 'react'
+import CmsMediaRewriter from '@/spa/components/CmsMediaRewriter'
import Layout from '@/spa/components/layout/Layout'
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
import type { SpaFooterData, SpaHeaderData } from '@/spa/cmsNavigation'
+import type { SpaMediaMap } from '@/spa/cmsMedia'
import About from '@/spa/pages/About'
import BlogDetail from '@/spa/pages/BlogDetail'
import Contact from '@/spa/pages/Contact'
@@ -46,10 +48,12 @@ export default function NextApp({
pathname,
header,
footer,
+ media,
}: {
pathname: string
header?: SpaHeaderData
footer?: SpaFooterData
+ media?: SpaMediaMap
}) {
const [loading, setLoading] = useState(true)
@@ -78,6 +82,7 @@ export default function NextApp({
return (
<>
+
diff --git a/src/spa/cmsMedia.ts b/src/spa/cmsMedia.ts
new file mode 100644
index 0000000..7e1c24a
--- /dev/null
+++ b/src/spa/cmsMedia.ts
@@ -0,0 +1,48 @@
+import type { Media } from '@/payload-types'
+
+export type SpaMediaEntry = {
+ source: string
+ media?: { url?: string } | string | number | Media
+}
+
+export type SpaMediaMap = Record
+
+export const defaultSpaMediaSources = [
+ '/bmp-logo.png',
+ '/logo.png',
+ '/gala-preistraeger.jpg',
+ '/munich-skyline.jpg',
+ '/bavarian-flag.jpg',
+ '/rauten.webp',
+ '/website-template-OG.webp',
+ '/images/bmp-banner.jpg',
+ '/images/buehne-gewinner.jpg',
+ '/images/buehne-moderatoren.jpg',
+ '/images/ewif-logo.png',
+ '/images/gala-dinner.jpg',
+ '/images/gala-saal-overview.jpg',
+ '/images/gewinner-gruppenfoto.jpg',
+ '/images/mitglied-hero.jpg',
+ '/images/netzwerk-hero.jpg',
+ '/images/networking-innenhof.jpg',
+ '/images/partner-logos.png',
+ '/images/preisuebergabe.jpg',
+ '/images/preistraeger-award.jpg',
+ '/images/preistraeger-gruppe.jpg',
+ '/images/preistraeger-jubel.jpg',
+ '/images/saal-gedeckt.jpg',
+ '/images/saal-leer-atmosphaere.jpg',
+ '/images/tisch-detail.jpg',
+]
+
+const isMedia = (value: unknown): value is Media => Boolean(value && typeof value === 'object' && 'url' in value)
+
+export function normalizeSpaMediaMap(global: any): SpaMediaMap {
+ const entries: SpaMediaEntry[] = Array.isArray(global?.assets) ? global.assets : []
+
+ return entries.reduce((acc: SpaMediaMap, entry: SpaMediaEntry) => {
+ if (!entry?.source) return acc
+ if (isMedia(entry.media) && entry.media.url) acc[entry.source] = entry.media.url
+ return acc
+ }, {})
+}
diff --git a/src/spa/components/CmsMediaRewriter.tsx b/src/spa/components/CmsMediaRewriter.tsx
new file mode 100644
index 0000000..4b22ee6
--- /dev/null
+++ b/src/spa/components/CmsMediaRewriter.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { useEffect } from 'react'
+
+import type { SpaMediaMap } from '@/spa/cmsMedia'
+
+const CSS_URL_RE = /url\((['"]?)(.*?)\1\)/g
+
+function canonical(value: string) {
+ if (!value) return value
+ try {
+ const url = new URL(value, window.location.origin)
+ return url.origin === window.location.origin ? `${url.pathname}${url.search}` : value
+ } catch {
+ return value
+ }
+}
+
+function rewriteCssUrls(value: string, media: SpaMediaMap) {
+ return value.replace(CSS_URL_RE, (_match, quote: string, url: string) => {
+ const replacement = media[url] || media[canonical(url)]
+ return replacement ? `url(${quote}${replacement}${quote})` : _match
+ })
+}
+
+function rewrite(root: ParentNode, media: SpaMediaMap) {
+ root.querySelectorAll('img, source, video').forEach((node) => {
+ const el = node as HTMLImageElement | HTMLSourceElement | HTMLVideoElement
+ const current = el.getAttribute('src')
+ if (current) {
+ const replacement = media[current] || media[canonical(current)]
+ if (replacement && current !== replacement) el.setAttribute('src', replacement)
+ }
+
+ const poster = el.getAttribute('poster')
+ if (poster) {
+ const replacement = media[poster] || media[canonical(poster)]
+ if (replacement && poster !== replacement) el.setAttribute('poster', replacement)
+ }
+ })
+
+ root.querySelectorAll('[style*="url("]').forEach((el) => {
+ const backgroundImage = el.style.backgroundImage
+ if (backgroundImage) {
+ const rewritten = rewriteCssUrls(backgroundImage, media)
+ if (rewritten !== backgroundImage) el.style.backgroundImage = rewritten
+ }
+ })
+}
+
+export default function CmsMediaRewriter({ media }: { media?: SpaMediaMap }) {
+ useEffect(() => {
+ if (!media || Object.keys(media).length === 0) return
+
+ rewrite(document, media)
+
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) rewrite(node as Element, media)
+ })
+ if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) {
+ rewrite((mutation.target as Element).parentNode || document, media)
+ }
+ }
+ })
+
+ observer.observe(document.body, {
+ attributes: true,
+ attributeFilter: ['src', 'poster', 'style'],
+ childList: true,
+ subtree: true,
+ })
+
+ return () => observer.disconnect()
+ }, [media])
+
+ return null
+}