chore: add media migration scripts, migrate media.
This commit is contained in:
@@ -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",
|
||||
|
||||
44
src/SpaMedia/config.ts
Normal file
44
src/SpaMedia/config.ts
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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 <NextApp pathname={pathname} header={normalizeHeaderData(header)} footer={normalizeFooterData(footer)} />
|
||||
return (
|
||||
<NextApp
|
||||
pathname={pathname}
|
||||
header={normalizeHeaderData(header)}
|
||||
footer={normalizeFooterData(footer)}
|
||||
media={normalizeSpaMediaMap(spaMedia)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -112,10 +112,12 @@ export interface Config {
|
||||
globals: {
|
||||
header: Header;
|
||||
footer: Footer;
|
||||
'spa-media': SpaMedia;
|
||||
};
|
||||
globalsSelect: {
|
||||
header: HeaderSelect<false> | HeaderSelect<true>;
|
||||
footer: FooterSelect<false> | FooterSelect<true>;
|
||||
'spa-media': SpaMediaSelect<false> | SpaMediaSelect<true>;
|
||||
};
|
||||
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<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "spa-media_select".
|
||||
*/
|
||||
export interface SpaMediaSelect<T extends boolean = true> {
|
||||
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".
|
||||
|
||||
@@ -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,
|
||||
|
||||
208
src/scripts/migrate-spa-media.ts
Normal file
208
src/scripts/migrate-spa-media.ts
Normal file
@@ -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<string, string> = {
|
||||
'.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<string[]> {
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<File> {
|
||||
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)
|
||||
})
|
||||
@@ -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 (
|
||||
<>
|
||||
<CmsMediaRewriter media={media} />
|
||||
<ScrollToTop />
|
||||
<Layout header={header} footer={footer}>
|
||||
<Page />
|
||||
|
||||
48
src/spa/cmsMedia.ts
Normal file
48
src/spa/cmsMedia.ts
Normal file
@@ -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<string, string>
|
||||
|
||||
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
|
||||
}, {})
|
||||
}
|
||||
79
src/spa/components/CmsMediaRewriter.tsx
Normal file
79
src/spa/components/CmsMediaRewriter.tsx
Normal file
@@ -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<HTMLElement>('[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
|
||||
}
|
||||
Reference in New Issue
Block a user