feat: Add fresh database setup script and media handling improvements

- Introduced a new script for setting up a fresh database with ordered migration tasks.
- Added a utility function to find media by filename, improving media handling in various scripts.
- Updated preload scripts to utilize the new media fetching utility.
- Refactored datenschutz, impressum, and mitglied-werden fields to use stable IDs and anchor IDs.
- Enhanced home and network pages to accommodate new field structures.
- Added new preload script for global CMS settings.
- Improved code readability and organization across multiple files.
This commit is contained in:
syntaxbullet
2026-06-22 17:20:45 +02:00
parent 09a43062a0
commit 9545f2513d
30 changed files with 359 additions and 171 deletions

View File

@@ -17,7 +17,9 @@
"migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts", "migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts",
"migrate:spa-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts", "migrate:spa-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"setup:fresh-db": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/setup-fresh-database.ts",
"preload:blog-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-blog-detail-cms.ts", "preload:blog-detail-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-blog-detail-cms.ts",
"preload:globals-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-globals-cms.ts",
"preload:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts", "preload:about-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-about-page-cms.ts",
"preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts", "preload:contact-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-contact-page-cms.ts",
"preload:datenschutz-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-datenschutz-page-cms.ts", "preload:datenschutz-page-cms": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/preload-datenschutz-page-cms.ts",

View File

@@ -21,6 +21,11 @@ const sectionAdmin = (description: string) => ({
initCollapsed: true, initCollapsed: true,
}) })
const sectionsDefault = datenschutzContent.sections.map(({ id, ...section }) => ({
...section,
anchorId: id,
}))
export const datenschutzFields: Field[] = [ export const datenschutzFields: Field[] = [
{ {
name: 'datenschutz', name: 'datenschutz',
@@ -60,9 +65,9 @@ export const datenschutzFields: Field[] = [
type: 'array', type: 'array',
dbName: 'data_sections', dbName: 'data_sections',
admin: sectionAdmin('Each section controls one heading, table-of-contents label, and ordered JSON body blocks.'), admin: sectionAdmin('Each section controls one heading, table-of-contents label, and ordered JSON body blocks.'),
defaultValue: datenschutzContent.sections, defaultValue: sectionsDefault,
fields: [ fields: [
text('id', 'Anchor ID'), text('anchorId', 'Anchor ID'),
text('tocLabel', 'TOC label'), text('tocLabel', 'TOC label'),
text('title', 'Section title'), text('title', 'Section title'),
{ {

View File

@@ -180,8 +180,20 @@ export const homeFields: Field[] = [
maxRows: 3, maxRows: 3,
defaultValue: homeContent.status.phases, defaultValue: homeContent.status.phases,
fields: [ fields: [
text('num', 'Slide number'), text('tag', 'Badge'), { name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true }, text('phase', 'Phase label'), text('headline', 'Headline'), textarea('body', 'Body copy'), text('num', 'Slide number'),
{ name: 'cta', label: 'Slide CTA', type: 'group', fields: linkGroup('', '') }, text('accent', 'Accent color'), text('bg', 'Background color'), text('glow', 'Glow color'), text('meta', 'Meta label'), text('successApplications', 'Success applications number'), text('successWinners', 'Success winners number'), { name: 'membershipCta', label: 'Membership CTA', type: 'group', fields: linkGroup('Mitglied werden', '/mitglied-werden') }, text('tag', 'Badge'),
{ name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true },
text('phase', 'Phase label'),
text('headline', 'Headline'),
textarea('body', 'Body copy'),
{ name: 'cta', label: 'Slide CTA', type: 'group', fields: linkGroup('', '') },
text('accent', 'Accent color'),
text('bg', 'Background color'),
text('glow', 'Glow color'),
text('meta', 'Meta label'),
text('successApplications', 'Success applications number'),
text('successWinners', 'Success winners number'),
{ name: 'membershipCta', label: 'Membership CTA', type: 'group', fields: linkGroup('Mitglied werden', '/mitglied-werden') },
], ],
}, },
text('noActionLabel', 'No CTA fallback label', homeContent.status.noActionLabel), text('noActionLabel', 'No CTA fallback label', homeContent.status.noActionLabel),

View File

@@ -21,6 +21,11 @@ const sectionAdmin = (description: string) => ({
initCollapsed: true, initCollapsed: true,
}) })
const sectionsDefault = impressumContent.sections.map(({ id, ...section }) => ({
...section,
anchorId: id,
}))
export const impressumFields: Field[] = [ export const impressumFields: Field[] = [
{ {
name: 'impressum', name: 'impressum',
@@ -60,9 +65,9 @@ export const impressumFields: Field[] = [
type: 'array', type: 'array',
dbName: 'imp_sections', dbName: 'imp_sections',
admin: sectionAdmin('Each section controls one heading, table-of-contents label, and ordered JSON body blocks.'), admin: sectionAdmin('Each section controls one heading, table-of-contents label, and ordered JSON body blocks.'),
defaultValue: impressumContent.sections, defaultValue: sectionsDefault,
fields: [ fields: [
text('id', 'Anchor ID'), text('anchorId', 'Anchor ID'),
text('tocLabel', 'TOC label'), text('tocLabel', 'TOC label'),
text('title', 'Section title'), text('title', 'Section title'),
{ {

View File

@@ -46,6 +46,10 @@ const sectionAdmin = (description: string) => ({
const statFields: Field[] = [text('value', 'Value'), text('label', 'Label')] const statFields: Field[] = [text('value', 'Value'), text('label', 'Label')]
const factFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')] const factFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')]
const faqFields: Field[] = [text('q', 'Question'), textarea('a', 'Answer')] const faqFields: Field[] = [text('q', 'Question'), textarea('a', 'Answer')]
const applicationOptionDefaults = mitgliedWerdenContent.applicationOptions.items.map(({ id, ...item }) => ({
...item,
stableId: id,
}))
const testimonialFields: Field[] = [ const testimonialFields: Field[] = [
textarea('quote', 'Quote'), textarea('quote', 'Quote'),
text('initials', 'Initials'), text('initials', 'Initials'),
@@ -204,9 +208,9 @@ export const mitgliedWerdenFields: Field[] = [
label: 'Options', label: 'Options',
type: 'array', type: 'array',
dbName: 'member_app_options', dbName: 'member_app_options',
defaultValue: mitgliedWerdenContent.applicationOptions.items, defaultValue: applicationOptionDefaults,
fields: [ fields: [
text('id', 'Stable ID'), text('stableId', 'Stable ID'),
text('number', 'Number'), text('number', 'Number'),
text('eyebrow', 'Eyebrow'), text('eyebrow', 'Eyebrow'),
text('title', 'Title'), text('title', 'Title'),

View File

@@ -59,7 +59,7 @@ const juryMemberFields: Field[] = [
const rowFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')] const rowFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')]
const partnerFields: Field[] = [ const partnerFields: Field[] = [
text('id', 'Stable ID'), text('stableId', 'Stable ID'),
text('name', 'Name'), text('name', 'Name'),
text('role', 'Role'), text('role', 'Role'),
numberField('tier', 'Tier'), numberField('tier', 'Tier'),
@@ -94,6 +94,10 @@ const partnerFields: Field[] = [
const benefitFields: Field[] = [text('num', 'Number'), text('label', 'Label'), text('sub', 'Subline')] const benefitFields: Field[] = [text('num', 'Number'), text('label', 'Label'), text('sub', 'Subline')]
const packageFields: Field[] = [text('value', 'Value'), text('title', 'Title'), textarea('desc', 'Description')] const packageFields: Field[] = [text('value', 'Value'), text('title', 'Title'), textarea('desc', 'Description')]
const formStepFields: Field[] = [text('label', 'Label')] const formStepFields: Field[] = [text('label', 'Label')]
const partnerDefaults = netzwerkContent.partners.items.map(({ id, ...partner }) => ({
...partner,
stableId: id,
}))
export const netzwerkFields: Field[] = [ export const netzwerkFields: Field[] = [
{ {
@@ -252,7 +256,7 @@ export const netzwerkFields: Field[] = [
label: 'Partners', label: 'Partners',
type: 'array', type: 'array',
dbName: 'network_partners', dbName: 'network_partners',
defaultValue: netzwerkContent.partners.items, defaultValue: partnerDefaults,
fields: partnerFields, fields: partnerFields,
}, },
], ],

View File

@@ -1358,7 +1358,7 @@ export interface Page {
*/ */
sections?: sections?:
| { | {
id?: string | null; anchorId?: string | null;
tocLabel?: string | null; tocLabel?: string | null;
title?: string | null; title?: string | null;
/** /**
@@ -1373,6 +1373,7 @@ export interface Page {
| number | number
| boolean | boolean
| null; | null;
id?: string | null;
}[] }[]
| null; | null;
}; };
@@ -1402,7 +1403,7 @@ export interface Page {
*/ */
sections?: sections?:
| { | {
id?: string | null; anchorId?: string | null;
tocLabel?: string | null; tocLabel?: string | null;
title?: string | null; title?: string | null;
/** /**
@@ -1417,6 +1418,7 @@ export interface Page {
| number | number
| boolean | boolean
| null; | null;
id?: string | null;
}[] }[]
| null; | null;
}; };
@@ -1823,7 +1825,7 @@ export interface Page {
}; };
items?: items?:
| { | {
id?: string | null; stableId?: string | null;
name?: string | null; name?: string | null;
role?: string | null; role?: string | null;
tier?: number | null; tier?: number | null;
@@ -1838,6 +1840,7 @@ export interface Page {
website?: string | null; website?: string | null;
linkedin?: string | null; linkedin?: string | null;
heroImg?: string | null; heroImg?: string | null;
id?: string | null;
}[] }[]
| null; | null;
}; };
@@ -2022,7 +2025,7 @@ export interface Page {
applicationOptions?: { applicationOptions?: {
items?: items?:
| { | {
id?: string | null; stableId?: string | null;
number?: string | null; number?: string | null;
eyebrow?: string | null; eyebrow?: string | null;
title?: string | null; title?: string | null;
@@ -2030,6 +2033,7 @@ export interface Page {
href?: string | null; href?: string | null;
file?: (number | null) | Media; file?: (number | null) | Media;
download?: boolean | null; download?: boolean | null;
id?: string | null;
}[] }[]
| null; | null;
}; };
@@ -4304,10 +4308,11 @@ export interface PagesSelect<T extends boolean = true> {
sections?: sections?:
| T | T
| { | {
id?: T; anchorId?: T;
tocLabel?: T; tocLabel?: T;
title?: T; title?: T;
items?: T; items?: T;
id?: T;
}; };
}; };
datenschutz?: datenschutz?:
@@ -4331,10 +4336,11 @@ export interface PagesSelect<T extends boolean = true> {
sections?: sections?:
| T | T
| { | {
id?: T; anchorId?: T;
tocLabel?: T; tocLabel?: T;
title?: T; title?: T;
items?: T; items?: T;
id?: T;
}; };
}; };
preistraegerIndex?: preistraegerIndex?:
@@ -4703,7 +4709,7 @@ export interface PagesSelect<T extends boolean = true> {
items?: items?:
| T | T
| { | {
id?: T; stableId?: T;
name?: T; name?: T;
role?: T; role?: T;
tier?: T; tier?: T;
@@ -4718,6 +4724,7 @@ export interface PagesSelect<T extends boolean = true> {
website?: T; website?: T;
linkedin?: T; linkedin?: T;
heroImg?: T; heroImg?: T;
id?: T;
}; };
}; };
sponsoring?: sponsoring?:
@@ -4887,7 +4894,7 @@ export interface PagesSelect<T extends boolean = true> {
items?: items?:
| T | T
| { | {
id?: T; stableId?: T;
number?: T; number?: T;
eyebrow?: T; eyebrow?: T;
title?: T; title?: T;
@@ -4895,6 +4902,7 @@ export interface PagesSelect<T extends boolean = true> {
href?: T; href?: T;
file?: T; file?: T;
download?: T; download?: T;
id?: T;
}; };
}; };
formIntro?: formIntro?:

View File

@@ -0,0 +1,35 @@
import path from 'path'
import type { Payload } from 'payload'
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export async function findMediaByFilename(payload: Payload, filename: string) {
const exact = await payload.find({
collection: 'media',
depth: 0,
limit: 1,
pagination: false,
where: { filename: { equals: filename } },
})
if (exact.docs[0]) return exact.docs[0]
const ext = path.extname(filename)
const basename = path.basename(filename, ext)
const suffixedFilename = new RegExp(`^${escapeRegExp(basename)}-\\d+${escapeRegExp(ext)}$`)
const candidates = await payload.find({
collection: 'media',
depth: 0,
limit: 100,
pagination: false,
sort: '-createdAt',
where: { filename: { contains: basename } },
})
return candidates.docs.find((doc) => doc.filename && suffixedFilename.test(doc.filename))
}
export async function mediaByFilename(payload: Payload, filename: string) {
return (await findMediaByFilename(payload, filename))?.id
}

View File

@@ -4,6 +4,7 @@ import 'dotenv/config'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getPayload, type File, type Payload } from 'payload' import { getPayload, type File, type Payload } from 'payload'
import { findMediaByFilename } from './mediaByFilename'
const root = process.cwd() const root = process.cwd()
const spaDir = path.join(root, 'src/spa') const spaDir = path.join(root, 'src/spa')
@@ -132,13 +133,7 @@ function altForSource(source: string) {
} }
async function findExistingMedia(payload: Payload, filename: string) { async function findExistingMedia(payload: Payload, filename: string) {
const existing = await payload.find({ return findMediaByFilename(payload, filename)
collection: 'media',
depth: 0,
limit: 1,
where: { filename: { equals: filename } },
})
return existing.docs[0]
} }
async function main() { async function main() {

View File

@@ -11,6 +11,7 @@ import { events } from '@/spa/data/events'
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
import { WINNERS } from '@/spa/data/winners' import { WINNERS } from '@/spa/data/winners'
import { spaPages, type SpaPageRegistryEntry } from '@/spa/pageRegistry' import { spaPages, type SpaPageRegistryEntry } from '@/spa/pageRegistry'
import { mediaByFilename } from './mediaByFilename'
type Payload = Awaited<ReturnType<typeof getPayload>> type Payload = Awaited<ReturnType<typeof getPayload>>
type TargetCollection = 'pages' | 'posts' | 'events' | 'preistraeger' type TargetCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
@@ -96,25 +97,7 @@ function baseData(page: SpaPageRegistryEntry) {
} }
function pageData(page: SpaPageRegistryEntry) { function pageData(page: SpaPageRegistryEntry) {
return { return baseData(page)
...baseData(page),
hero: {
type: 'none' as const,
},
layout: [
{
blockType: 'content' as const,
columns: [
{
size: 'full' as const,
richText: emptyRichText(
`Diese Seite wird aktuell noch von der migrierten SPA gerendert. Über „Preview“ kann der aktuelle Stand unter ${page.path} betrachtet werden.`,
),
},
],
},
],
}
} }
function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) { function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
@@ -299,15 +282,8 @@ async function loadMediaMap(payload: Payload): Promise<MediaMap> {
]) ])
const assets = await Promise.all( const assets = await Promise.all(
[...sources].map(async (source) => { [...sources].map(async (source) => {
const existing = await payload.find({ const media = await mediaByFilename(payload, filenameForSource(source))
collection: 'media', return media ? ([source, media] as const) : undefined
depth: 0,
limit: 1,
pagination: false,
where: { filename: { equals: filenameForSource(source) } },
})
const media = existing.docs[0]
return media ? ([source, media.id] as const) : undefined
}), }),
) )
@@ -320,7 +296,12 @@ async function main() {
payload.logger.info(`Migrating ${spaPages.length} SPA routes into Payload collections...`) payload.logger.info(`Migrating ${spaPages.length} SPA routes into Payload collections...`)
for (const page of spaPages) { for (const page of spaPages) {
try {
await upsertPage(payload, page, mediaMap) await upsertPage(payload, page, mediaMap)
} catch (error) {
payload.logger.error({ msg: `Failed to migrate SPA route ${page.path} into ${targetForPage(page)}`, err: error })
throw error
}
} }
payload.logger.info('SPA page migration complete.') payload.logger.info('SPA page migration complete.')

View File

@@ -3,19 +3,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import crypto from 'crypto' import crypto from 'crypto'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { aboutContent } from '@/spa/aboutContent' import { aboutContent } from '@/spa/aboutContent'
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
}
const safeRemoteName = (url: string) => { const safeRemoteName = (url: string) => {
const parsed = new URL(url) const parsed = new URL(url)
const ext = parsed.pathname.split('/').pop()?.includes('.') ? `.${parsed.pathname.split('.').pop()}` : '.jpg' const ext = parsed.pathname.split('/').pop()?.includes('.') ? `.${parsed.pathname.split('.').pop()}` : '.jpg'
@@ -104,6 +95,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded About CMS fields for page ${page.id}`) payload.logger.info(`Preloaded About CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { blogDetailContent } from '@/spa/blogDetailContent' import { blogDetailContent } from '@/spa/blogDetailContent'
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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const fallbackImage = await mediaByFilename(payload, blogDetailContent.fallbackImageFilename) const fallbackImage = await mediaByFilename(payload, blogDetailContent.fallbackImageFilename)
@@ -47,6 +38,7 @@ async function main() {
) )
payload.logger.info(`Preloaded Blog detail CMS fields for ${posts.docs.length} records`) payload.logger.info(`Preloaded Blog detail CMS fields for ${posts.docs.length} records`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { contactContent } from '@/spa/contactContent' import { contactContent } from '@/spa/contactContent'
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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@@ -61,6 +52,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded contact CMS fields for page ${page.id}`) payload.logger.info(`Preloaded contact CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -5,6 +5,14 @@ import { getPayload } from 'payload'
import { datenschutzContent } from '@/spa/datenschutzContent' import { datenschutzContent } from '@/spa/datenschutzContent'
const contentForPayload = {
...datenschutzContent,
sections: datenschutzContent.sections.map(({ id, ...section }) => ({
...section,
anchorId: id,
})),
}
async function main() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@@ -28,11 +36,12 @@ async function main() {
overrideAccess: true, overrideAccess: true,
context: { disableRevalidate: true }, context: { disableRevalidate: true },
data: { data: {
datenschutz: datenschutzContent, datenschutz: contentForPayload,
} as never, } as never,
}) })
payload.logger.info(`Preloaded Datenschutz CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Datenschutz CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { eventDetailContent } from '@/spa/eventDetailContent' import { eventDetailContent } from '@/spa/eventDetailContent'
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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const fallbackImage = await mediaByFilename(payload, eventDetailContent.fallbackImageFilename) const fallbackImage = await mediaByFilename(payload, eventDetailContent.fallbackImageFilename)
@@ -47,6 +38,7 @@ async function main() {
) )
payload.logger.info(`Preloaded Event detail CMS fields for ${events.docs.length} records`) payload.logger.info(`Preloaded Event detail CMS fields for ${events.docs.length} records`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,6 +2,7 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { formularUploadContent } from '@/spa/formularUploadContent' import { formularUploadContent } from '@/spa/formularUploadContent'
@@ -33,6 +34,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded Formular upload CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Formular upload CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -0,0 +1,67 @@
import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { defaultFooterData, defaultHeaderData } from '@/spa/cmsNavigation'
import { defaultSiteSettings } from '@/spa/siteSettings'
async function main() {
const payload = await getPayload({ config })
const [logo, ewifLogo, partnerLogos, skylineImage] = await Promise.all([
mediaByFilename(payload, 'bmp-logo.png'),
mediaByFilename(payload, 'ewif-logo.png'),
mediaByFilename(payload, 'partner-logos.png'),
mediaByFilename(payload, 'munich-skyline.jpg'),
])
const { logo: _headerLogo, languages, ...headerData } = defaultHeaderData
const { logo: _footerLogo, partnerLogos: _defaultPartnerLogos, ...footerData } = defaultFooterData
const { skylineImageUrl: _skylineImageUrl, ...siteSettingsData } = defaultSiteSettings
await Promise.all([
payload.updateGlobal({
slug: 'header',
context: { disableRevalidate: true },
data: {
...headerData,
logo,
languages: languages.map((label) => ({ label })),
} as never,
}),
payload.updateGlobal({
slug: 'footer',
context: { disableRevalidate: true },
data: {
...footerData,
logo,
partnerLogos: [
{ alt: 'EWIF – Europäisches Wirtschaftsforum', image: ewifLogo },
{
alt: 'Hochschule für angewandtes Management · Gipfeldialog Altaussee',
image: partnerLogos,
},
].filter((item) => item.image),
} as never,
}),
payload.updateGlobal({
slug: 'site-settings',
context: { disableRevalidate: true },
data: {
...siteSettingsData,
skylineImage,
} as never,
}),
])
payload.logger.info('Preloaded global CMS settings.')
await payload.destroy()
process.exit(0)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -3,19 +3,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import crypto from 'crypto' import crypto from 'crypto'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { homeContent } from '@/spa/homeContent' import { homeContent } from '@/spa/homeContent'
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
}
const safeRemoteName = (url: string) => { const safeRemoteName = (url: string) => {
const parsed = new URL(url) const parsed = new URL(url)
const ext = parsed.pathname.split('/').pop()?.includes('.') ? `.${parsed.pathname.split('.').pop()}` : '.jpg' const ext = parsed.pathname.split('/').pop()?.includes('.') ? `.${parsed.pathname.split('.').pop()}` : '.jpg'
@@ -115,6 +106,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded home CMS fields for page ${page.id}`) payload.logger.info(`Preloaded home CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -5,6 +5,14 @@ import { getPayload } from 'payload'
import { impressumContent } from '@/spa/impressumContent' import { impressumContent } from '@/spa/impressumContent'
const contentForPayload = {
...impressumContent,
sections: impressumContent.sections.map(({ id, ...section }) => ({
...section,
anchorId: id,
})),
}
async function main() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@@ -28,11 +36,12 @@ async function main() {
overrideAccess: true, overrideAccess: true,
context: { disableRevalidate: true }, context: { disableRevalidate: true },
data: { data: {
impressum: impressumContent, impressum: contentForPayload,
} as never, } as never,
}) })
payload.logger.info(`Preloaded Impressum CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Impressum CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { mitgliedWerdenContent } from '@/spa/mitgliedWerdenContent' import { mitgliedWerdenContent } from '@/spa/mitgliedWerdenContent'
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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@@ -42,9 +33,10 @@ async function main() {
const formImage = await mediaByFilename(payload, mitgliedWerdenContent.formIntro.imageFilename) const formImage = await mediaByFilename(payload, mitgliedWerdenContent.formIntro.imageFilename)
const applicationItems = await Promise.all( const applicationItems = await Promise.all(
mitgliedWerdenContent.applicationOptions.items.map(async (item) => { mitgliedWerdenContent.applicationOptions.items.map(async (item) => {
const { fileFilename, ...rest } = item const { fileFilename, id, ...rest } = item
return { return {
...rest, ...rest,
stableId: id,
file: fileFilename ? await mediaByFilename(payload, fileFilename) : undefined, file: fileFilename ? await mediaByFilename(payload, fileFilename) : undefined,
} }
}), }),
@@ -52,6 +44,10 @@ async function main() {
const { imageFilename: _heroFilename, ...heroContent } = mitgliedWerdenContent.hero const { imageFilename: _heroFilename, ...heroContent } = mitgliedWerdenContent.hero
const { imageFilename: _formFilename, ...formIntroContent } = mitgliedWerdenContent.formIntro const { imageFilename: _formFilename, ...formIntroContent } = mitgliedWerdenContent.formIntro
const aboutContent = {
...mitgliedWerdenContent.about,
paragraphs: mitgliedWerdenContent.about.paragraphs.map((text) => ({ text })),
}
await payload.update({ await payload.update({
collection: 'pages', collection: 'pages',
@@ -65,6 +61,7 @@ async function main() {
...heroContent, ...heroContent,
image: heroImage, image: heroImage,
}, },
about: aboutContent,
applicationOptions: { applicationOptions: {
...mitgliedWerdenContent.applicationOptions, ...mitgliedWerdenContent.applicationOptions,
items: applicationItems, items: applicationItems,
@@ -78,6 +75,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded Mitglied werden CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Mitglied werden CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -3,19 +3,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import crypto from 'crypto' import crypto from 'crypto'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { netzwerkContent } from '@/spa/netzwerkContent' import { netzwerkContent } from '@/spa/netzwerkContent'
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
}
const safeRemoteName = (url: string) => { const safeRemoteName = (url: string) => {
const parsed = new URL(url) const parsed = new URL(url)
const ext = parsed.pathname.split('/').pop()?.includes('.') ? `.${parsed.pathname.split('.').pop()}` : '.jpg' const ext = parsed.pathname.split('/').pop()?.includes('.') ? `.${parsed.pathname.split('.').pop()}` : '.jpg'
@@ -39,6 +30,11 @@ const itemsWithImageUploads = async <T extends { image?: string }>(payload: Payl
}), }),
) )
const partnersForPayload = netzwerkContent.partners.items.map(({ id, ...partner }) => ({
...partner,
stableId: id,
}))
async function main() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@@ -89,11 +85,16 @@ async function main() {
...sponsoringContent, ...sponsoringContent,
image: sponsoringImage, image: sponsoringImage,
}, },
partners: {
...netzwerkContent.partners,
items: partnersForPayload,
},
}, },
} as never, } as never,
}) })
payload.logger.info(`Preloaded Netzwerk CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Netzwerk CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { participationContent } from '@/spa/participationContent' import { participationContent } from '@/spa/participationContent'
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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
@@ -70,6 +61,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded participation CMS fields for page ${page.id}`) payload.logger.info(`Preloaded participation CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const [fallbackImage, sideImage] = await Promise.all([ const [fallbackImage, sideImage] = await Promise.all([
@@ -54,6 +45,7 @@ async function main() {
) )
payload.logger.info(`Preloaded Preisträger detail CMS fields for ${winners.docs.length} records`) payload.logger.info(`Preloaded Preisträger detail CMS fields for ${winners.docs.length} records`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent' 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() { async function main() {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const fallbackImage = await mediaByFilename(payload, preistraegerIndexContent.card.fallbackImageFilename) const fallbackImage = await mediaByFilename(payload, preistraegerIndexContent.card.fallbackImageFilename)
@@ -51,6 +42,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded Preisträger index CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Preisträger index CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config' import config from '@payload-config'
import { getPayload, type Payload } from 'payload' import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { pressIndexContent } from '@/spa/pressIndexContent' import { pressIndexContent } from '@/spa/pressIndexContent'
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
}
const filenameFromPath = (source: string) => source.split('?')[0]?.split('/').filter(Boolean).pop() const filenameFromPath = (source: string) => source.split('?')[0]?.split('/').filter(Boolean).pop()
const fallbackItemsWithImages = async <T extends { img?: string }>( const fallbackItemsWithImages = async <T extends { img?: string }>(
@@ -93,6 +84,7 @@ async function main() {
}) })
payload.logger.info(`Preloaded Press index CMS fields for page ${page.id}`) payload.logger.info(`Preloaded Press index CMS fields for page ${page.id}`)
process.exit(0)
} }
main().catch((error) => { main().catch((error) => {

View File

@@ -0,0 +1,116 @@
import 'dotenv/config'
import config from '@payload-config'
import { spawnSync } from 'node:child_process'
import { getPayload } from 'payload'
const orderedScripts = [
{
label: 'Import media assets',
path: 'src/scripts/migrate-spa-media.ts',
},
{
label: 'Create base pages, posts, events, and Preisträger records',
path: 'src/scripts/migrate-spa-pages.ts',
},
{
label: 'Store header, footer, and site settings defaults',
path: 'src/scripts/preload-globals-cms.ts',
},
{
label: 'Preload detail page copy for blog posts',
path: 'src/scripts/preload-blog-detail-cms.ts',
},
{
label: 'Preload detail page copy for events',
path: 'src/scripts/preload-event-detail-cms.ts',
},
{
label: 'Preload detail page copy for Preisträger',
path: 'src/scripts/preload-preistraeger-detail-cms.ts',
},
{
label: 'Preload home page',
path: 'src/scripts/preload-home-page-cms.ts',
},
{
label: 'Preload about page',
path: 'src/scripts/preload-about-page-cms.ts',
},
{
label: 'Preload contact page',
path: 'src/scripts/preload-contact-page-cms.ts',
},
{
label: 'Preload Datenschutz page',
path: 'src/scripts/preload-datenschutz-page-cms.ts',
},
{
label: 'Preload formular upload page',
path: 'src/scripts/preload-formular-upload-page-cms.ts',
},
{
label: 'Preload Impressum page',
path: 'src/scripts/preload-impressum-page-cms.ts',
},
{
label: 'Preload Mitglied werden page',
path: 'src/scripts/preload-mitglied-werden-page-cms.ts',
},
{
label: 'Preload Netzwerk page',
path: 'src/scripts/preload-netzwerk-page-cms.ts',
},
{
label: 'Preload participation page',
path: 'src/scripts/preload-participation-page-cms.ts',
},
{
label: 'Preload Preisträger index page',
path: 'src/scripts/preload-preistraeger-index-page-cms.ts',
},
{
label: 'Preload press index page',
path: 'src/scripts/preload-press-index-page-cms.ts',
},
]
async function initializeSchema() {
const payload = await getPayload({ config })
payload.logger.info(
`Initialized Payload schema for ${Object.keys(payload.collections).length} collections and ${
Object.keys(payload.globals.config).length
} globals.`,
)
await payload.destroy()
}
function runScript(scriptPath: string) {
const result = spawnSync('tsx', [scriptPath], {
env: process.env,
stdio: 'inherit',
})
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${scriptPath} failed with exit code ${result.status ?? 'unknown'}`)
}
async function main() {
console.log('Setting up a fresh Payload database...')
console.log('1. Initialize Payload tables')
await initializeSchema()
for (const [index, script] of orderedScripts.entries()) {
console.log(`${index + 2}. ${script.label}`)
runScript(script.path)
}
console.log('Fresh database setup complete.')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -29,6 +29,9 @@ export type LegalPageContent = {
} }
type LegalPageCms = Partial<LegalPageContent> type LegalPageCms = Partial<LegalPageContent>
type LegalSectionCms = LegalSectionContent & {
anchorId?: string
}
const fallbackText = (value: unknown, fallback: string) => (typeof value === 'string' && value.length > 0 ? value : fallback) const fallbackText = (value: unknown, fallback: string) => (typeof value === 'string' && value.length > 0 ? value : fallback)
@@ -42,7 +45,10 @@ export default function LegalPage({ content, cmsKey }: { content: LegalPageConte
const cms = (useCmsRoute()?.doc?.[cmsKey] || {}) as LegalPageCms const cms = (useCmsRoute()?.doc?.[cmsKey] || {}) as LegalPageCms
const hero = { ...content.hero, ...(cms.hero || {}) } const hero = { ...content.hero, ...(cms.hero || {}) }
const navigation = { ...content.navigation, ...(cms.navigation || {}) } const navigation = { ...content.navigation, ...(cms.navigation || {}) }
const sections = fallbackArray<LegalSectionContent>(cms.sections, content.sections) const sections = fallbackArray<LegalSectionCms>(cms.sections, content.sections).map((section) => ({
...section,
id: section.anchorId || section.id,
}))
return ( return (
<div style={{ background: CREAM, minHeight: '100vh' }}> <div style={{ background: CREAM, minHeight: '100vh' }}>

View File

@@ -90,7 +90,7 @@ export const homeContent = {
headline: 'DIE JURY BEWERTET GERADE.', headline: 'DIE JURY BEWERTET GERADE.',
body: body:
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.', 'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
cta: null, cta: {},
accent: '#4A8FC9', accent: '#4A8FC9',
bg: '#060D1C', bg: '#060D1C',
glow: 'rgba(74,143,201,0.12)', glow: 'rgba(74,143,201,0.12)',

View File

@@ -596,7 +596,7 @@ function StatusSlider({ data }: { data?: HomeCms }) {
const phases = fallbackArray(data?.phases, homeContent.status.phases).map((phase, i) => { const phases = fallbackArray(data?.phases, homeContent.status.phases).map((phase, i) => {
const phaseRecord = phase as StatusPhase const phaseRecord = phase as StatusPhase
const fallbackPhase = homeContent.status.phases[i] const fallbackPhase = homeContent.status.phases[i]
const fallbackCta = fallbackPhase?.cta const fallbackCta = fallbackPhase?.cta?.label
? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url } ? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url }
: null : null
const cta = phaseRecord?.cta?.label const cta = phaseRecord?.cta?.label

View File

@@ -30,7 +30,7 @@ type NetzwerkCms = Partial<typeof netzwerkContent> & {
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown } type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown } type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown }
type Partner = (typeof netzwerkContent.partners.items)[number] & { tier: 1 | 2 | 3; heroImg?: string; linkedin?: string; logoSubtext?: string } type Partner = (typeof netzwerkContent.partners.items)[number] & { stableId?: string; tier: 1 | 2 | 3; heroImg?: string; linkedin?: string; logoSubtext?: string }
type PartnerTier = (typeof netzwerkContent.partners.tiers)[number] type PartnerTier = (typeof netzwerkContent.partners.tiers)[number]
type ExpertiseRow = (typeof netzwerkContent.expertise.rows)[number] type ExpertiseRow = (typeof netzwerkContent.expertise.rows)[number]
type StatItem = (typeof netzwerkContent.juryIntro.stats)[number] type StatItem = (typeof netzwerkContent.juryIntro.stats)[number]
@@ -458,6 +458,7 @@ const Netzwerk: React.FC = () => {
const defaultLogoColor = fallbackText(partnerSection.defaultLogoColor, netzwerkContent.partners.defaultLogoColor); const defaultLogoColor = fallbackText(partnerSection.defaultLogoColor, netzwerkContent.partners.defaultLogoColor);
const partners = fallbackArray<Partner>(partnerSection.items, netzwerkContent.partners.items as Partner[]).map((partner) => ({ const partners = fallbackArray<Partner>(partnerSection.items, netzwerkContent.partners.items as Partner[]).map((partner) => ({
...partner, ...partner,
id: partner.stableId || partner.id,
logoColor: fallbackText(partner.logoColor, defaultLogoColor), logoColor: fallbackText(partner.logoColor, defaultLogoColor),
})); }));
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers); const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);