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",
|
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
|
||||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
|
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
|
||||||
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint . --fix",
|
"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",
|
"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",
|
"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",
|
"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 NextApp from '@/spa/NextApp'
|
||||||
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
||||||
|
import { normalizeSpaMediaMap } from '@/spa/cmsMedia'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -14,10 +15,18 @@ export default async function Page({ params }: Args) {
|
|||||||
const { slug = [] } = await params
|
const { slug = [] } = await params
|
||||||
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
|
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
|
||||||
const payload = await getPayload({ config: configPromise })
|
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: 'header', depth: 1 }).catch(() => undefined),
|
||||||
payload.findGlobal({ slug: 'footer', 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 */
|
/** @type import('payload').ImportMap */
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient':
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
"@payloadcms/next/client#SlugField": SlugField_2b8867833a34864a02ddf429b0728a40,
|
||||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/next/rsc#FolderTableCell": FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
"@payloadcms/next/rsc#FolderField": FolderField_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
'@payloadcms/plugin-seo/client#OverviewComponent':
|
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||||
OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
"@payloadcms/next/client#FolderTypeField": FolderTypeField_2b8867833a34864a02ddf429b0728a40,
|
||||||
'@payloadcms/plugin-seo/client#MetaTitleComponent':
|
"@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224,
|
||||||
MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
"@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466,
|
||||||
'@payloadcms/plugin-seo/client#MetaImageComponent':
|
"@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6,
|
||||||
MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
"@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e,
|
||||||
'@payloadcms/plugin-seo/client#MetaDescriptionComponent':
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
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: {
|
globals: {
|
||||||
header: Header;
|
header: Header;
|
||||||
footer: Footer;
|
footer: Footer;
|
||||||
|
'spa-media': SpaMedia;
|
||||||
};
|
};
|
||||||
globalsSelect: {
|
globalsSelect: {
|
||||||
header: HeaderSelect<false> | HeaderSelect<true>;
|
header: HeaderSelect<false> | HeaderSelect<true>;
|
||||||
footer: FooterSelect<false> | FooterSelect<true>;
|
footer: FooterSelect<false> | FooterSelect<true>;
|
||||||
|
'spa-media': SpaMediaSelect<false> | SpaMediaSelect<true>;
|
||||||
};
|
};
|
||||||
locale: null;
|
locale: null;
|
||||||
widgets: {
|
widgets: {
|
||||||
@@ -1756,6 +1758,27 @@ export interface Footer {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "header_select".
|
* via the `definition` "header_select".
|
||||||
@@ -1874,6 +1897,22 @@ export interface FooterSelect<T extends boolean = true> {
|
|||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "collections_widget".
|
* via the `definition` "collections_widget".
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Posts } from './collections/Posts'
|
|||||||
import { Users } from './collections/Users'
|
import { Users } from './collections/Users'
|
||||||
import { Footer } from './Footer/config'
|
import { Footer } from './Footer/config'
|
||||||
import { Header } from './Header/config'
|
import { Header } from './Header/config'
|
||||||
|
import { SpaMedia } from './SpaMedia/config'
|
||||||
import { plugins } from './plugins'
|
import { plugins } from './plugins'
|
||||||
import { defaultLexical } from '@/fields/defaultLexical'
|
import { defaultLexical } from '@/fields/defaultLexical'
|
||||||
import { getServerSideURL } from './utilities/getURL'
|
import { getServerSideURL } from './utilities/getURL'
|
||||||
@@ -64,7 +65,7 @@ export default buildConfig({
|
|||||||
}),
|
}),
|
||||||
collections: [Pages, Posts, Media, Categories, Users],
|
collections: [Pages, Posts, Media, Categories, Users],
|
||||||
cors: [getServerSideURL()].filter(Boolean),
|
cors: [getServerSideURL()].filter(Boolean),
|
||||||
globals: [Header, Footer],
|
globals: [Header, Footer, SpaMedia],
|
||||||
plugins,
|
plugins,
|
||||||
secret: process.env.PAYLOAD_SECRET,
|
secret: process.env.PAYLOAD_SECRET,
|
||||||
sharp,
|
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 React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import CmsMediaRewriter from '@/spa/components/CmsMediaRewriter'
|
||||||
import Layout from '@/spa/components/layout/Layout'
|
import Layout from '@/spa/components/layout/Layout'
|
||||||
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
|
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
|
||||||
import type { SpaFooterData, SpaHeaderData } from '@/spa/cmsNavigation'
|
import type { SpaFooterData, SpaHeaderData } from '@/spa/cmsNavigation'
|
||||||
|
import type { SpaMediaMap } from '@/spa/cmsMedia'
|
||||||
import About from '@/spa/pages/About'
|
import About from '@/spa/pages/About'
|
||||||
import BlogDetail from '@/spa/pages/BlogDetail'
|
import BlogDetail from '@/spa/pages/BlogDetail'
|
||||||
import Contact from '@/spa/pages/Contact'
|
import Contact from '@/spa/pages/Contact'
|
||||||
@@ -46,10 +48,12 @@ export default function NextApp({
|
|||||||
pathname,
|
pathname,
|
||||||
header,
|
header,
|
||||||
footer,
|
footer,
|
||||||
|
media,
|
||||||
}: {
|
}: {
|
||||||
pathname: string
|
pathname: string
|
||||||
header?: SpaHeaderData
|
header?: SpaHeaderData
|
||||||
footer?: SpaFooterData
|
footer?: SpaFooterData
|
||||||
|
media?: SpaMediaMap
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ export default function NextApp({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<CmsMediaRewriter media={media} />
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Layout header={header} footer={footer}>
|
<Layout header={header} footer={footer}>
|
||||||
<Page />
|
<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