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-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts",
"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: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: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",

View File

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

View File

@@ -180,8 +180,20 @@ export const homeFields: Field[] = [
maxRows: 3,
defaultValue: homeContent.status.phases,
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'),
{ 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('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'),
{ 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),

View File

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

View File

@@ -46,6 +46,10 @@ const sectionAdmin = (description: string) => ({
const statFields: Field[] = [text('value', 'Value'), text('label', 'Label')]
const factFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')]
const faqFields: Field[] = [text('q', 'Question'), textarea('a', 'Answer')]
const applicationOptionDefaults = mitgliedWerdenContent.applicationOptions.items.map(({ id, ...item }) => ({
...item,
stableId: id,
}))
const testimonialFields: Field[] = [
textarea('quote', 'Quote'),
text('initials', 'Initials'),
@@ -204,9 +208,9 @@ export const mitgliedWerdenFields: Field[] = [
label: 'Options',
type: 'array',
dbName: 'member_app_options',
defaultValue: mitgliedWerdenContent.applicationOptions.items,
defaultValue: applicationOptionDefaults,
fields: [
text('id', 'Stable ID'),
text('stableId', 'Stable ID'),
text('number', 'Number'),
text('eyebrow', 'Eyebrow'),
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 partnerFields: Field[] = [
text('id', 'Stable ID'),
text('stableId', 'Stable ID'),
text('name', 'Name'),
text('role', 'Role'),
numberField('tier', 'Tier'),
@@ -94,6 +94,10 @@ const partnerFields: Field[] = [
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 formStepFields: Field[] = [text('label', 'Label')]
const partnerDefaults = netzwerkContent.partners.items.map(({ id, ...partner }) => ({
...partner,
stableId: id,
}))
export const netzwerkFields: Field[] = [
{
@@ -252,7 +256,7 @@ export const netzwerkFields: Field[] = [
label: 'Partners',
type: 'array',
dbName: 'network_partners',
defaultValue: netzwerkContent.partners.items,
defaultValue: partnerDefaults,
fields: partnerFields,
},
],

View File

@@ -1358,7 +1358,7 @@ export interface Page {
*/
sections?:
| {
id?: string | null;
anchorId?: string | null;
tocLabel?: string | null;
title?: string | null;
/**
@@ -1373,6 +1373,7 @@ export interface Page {
| number
| boolean
| null;
id?: string | null;
}[]
| null;
};
@@ -1402,7 +1403,7 @@ export interface Page {
*/
sections?:
| {
id?: string | null;
anchorId?: string | null;
tocLabel?: string | null;
title?: string | null;
/**
@@ -1417,6 +1418,7 @@ export interface Page {
| number
| boolean
| null;
id?: string | null;
}[]
| null;
};
@@ -1823,7 +1825,7 @@ export interface Page {
};
items?:
| {
id?: string | null;
stableId?: string | null;
name?: string | null;
role?: string | null;
tier?: number | null;
@@ -1838,6 +1840,7 @@ export interface Page {
website?: string | null;
linkedin?: string | null;
heroImg?: string | null;
id?: string | null;
}[]
| null;
};
@@ -2022,7 +2025,7 @@ export interface Page {
applicationOptions?: {
items?:
| {
id?: string | null;
stableId?: string | null;
number?: string | null;
eyebrow?: string | null;
title?: string | null;
@@ -2030,6 +2033,7 @@ export interface Page {
href?: string | null;
file?: (number | null) | Media;
download?: boolean | null;
id?: string | null;
}[]
| null;
};
@@ -4304,10 +4308,11 @@ export interface PagesSelect<T extends boolean = true> {
sections?:
| T
| {
id?: T;
anchorId?: T;
tocLabel?: T;
title?: T;
items?: T;
id?: T;
};
};
datenschutz?:
@@ -4331,10 +4336,11 @@ export interface PagesSelect<T extends boolean = true> {
sections?:
| T
| {
id?: T;
anchorId?: T;
tocLabel?: T;
title?: T;
items?: T;
id?: T;
};
};
preistraegerIndex?:
@@ -4703,7 +4709,7 @@ export interface PagesSelect<T extends boolean = true> {
items?:
| T
| {
id?: T;
stableId?: T;
name?: T;
role?: T;
tier?: T;
@@ -4718,6 +4724,7 @@ export interface PagesSelect<T extends boolean = true> {
website?: T;
linkedin?: T;
heroImg?: T;
id?: T;
};
};
sponsoring?:
@@ -4887,7 +4894,7 @@ export interface PagesSelect<T extends boolean = true> {
items?:
| T
| {
id?: T;
stableId?: T;
number?: T;
eyebrow?: T;
title?: T;
@@ -4895,6 +4902,7 @@ export interface PagesSelect<T extends boolean = true> {
href?: T;
file?: T;
download?: T;
id?: T;
};
};
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 path from 'path'
import { getPayload, type File, type Payload } from 'payload'
import { findMediaByFilename } from './mediaByFilename'
const root = process.cwd()
const spaDir = path.join(root, 'src/spa')
@@ -132,13 +133,7 @@ function altForSource(source: string) {
}
async function findExistingMedia(payload: Payload, filename: string) {
const existing = await payload.find({
collection: 'media',
depth: 0,
limit: 1,
where: { filename: { equals: filename } },
})
return existing.docs[0]
return findMediaByFilename(payload, filename)
}
async function main() {

View File

@@ -11,6 +11,7 @@ import { events } from '@/spa/data/events'
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
import { WINNERS } from '@/spa/data/winners'
import { spaPages, type SpaPageRegistryEntry } from '@/spa/pageRegistry'
import { mediaByFilename } from './mediaByFilename'
type Payload = Awaited<ReturnType<typeof getPayload>>
type TargetCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
@@ -96,25 +97,7 @@ function baseData(page: SpaPageRegistryEntry) {
}
function pageData(page: SpaPageRegistryEntry) {
return {
...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.`,
),
},
],
},
],
}
return baseData(page)
}
function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
@@ -299,15 +282,8 @@ async function loadMediaMap(payload: Payload): Promise<MediaMap> {
])
const assets = await Promise.all(
[...sources].map(async (source) => {
const existing = await payload.find({
collection: 'media',
depth: 0,
limit: 1,
pagination: false,
where: { filename: { equals: filenameForSource(source) } },
})
const media = existing.docs[0]
return media ? ([source, media.id] as const) : undefined
const media = await mediaByFilename(payload, filenameForSource(source))
return media ? ([source, media] as const) : undefined
}),
)
@@ -320,7 +296,12 @@ async function main() {
payload.logger.info(`Migrating ${spaPages.length} SPA routes into Payload collections...`)
for (const page of spaPages) {
try {
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.')

View File

@@ -3,19 +3,10 @@ import 'dotenv/config'
import config from '@payload-config'
import crypto from 'crypto'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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 parsed = new URL(url)
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}`)
process.exit(0)
}
main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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() {
const payload = await getPayload({ config })
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`)
process.exit(0)
}
main().catch((error) => {

View File

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

View File

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

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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() {
const payload = await getPayload({ config })
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`)
process.exit(0)
}
main().catch((error) => {

View File

@@ -2,6 +2,7 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { formularUploadContent } from '@/spa/formularUploadContent'
@@ -33,6 +34,7 @@ async function main() {
})
payload.logger.info(`Preloaded Formular upload CMS fields for page ${page.id}`)
process.exit(0)
}
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 crypto from 'crypto'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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 parsed = new URL(url)
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}`)
process.exit(0)
}
main().catch((error) => {

View File

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

View File

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

View File

@@ -3,19 +3,10 @@ import 'dotenv/config'
import config from '@payload-config'
import crypto from 'crypto'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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 parsed = new URL(url)
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() {
const payload = await getPayload({ config })
@@ -89,11 +85,16 @@ async function main() {
...sponsoringContent,
image: sponsoringImage,
},
partners: {
...netzwerkContent.partners,
items: partnersForPayload,
},
},
} as never,
})
payload.logger.info(`Preloaded Netzwerk CMS fields for page ${page.id}`)
process.exit(0)
}
main().catch((error) => {

View File

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

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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() {
const payload = await getPayload({ config })
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`)
process.exit(0)
}
main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent'
const mediaByFilename = async (payload: Payload, filename: string) => {
const result = await payload.find({
collection: 'media',
limit: 1,
pagination: false,
where: { filename: { equals: filename } },
})
return result.docs[0]?.id
}
async function main() {
const payload = await getPayload({ config })
const fallbackImage = await mediaByFilename(payload, preistraegerIndexContent.card.fallbackImageFilename)
@@ -51,6 +42,7 @@ async function main() {
})
payload.logger.info(`Preloaded Preisträger index CMS fields for page ${page.id}`)
process.exit(0)
}
main().catch((error) => {

View File

@@ -2,19 +2,10 @@ import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
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 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}`)
process.exit(0)
}
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 LegalSectionCms = LegalSectionContent & {
anchorId?: string
}
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 hero = { ...content.hero, ...(cms.hero || {}) }
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 (
<div style={{ background: CREAM, minHeight: '100vh' }}>

View File

@@ -90,7 +90,7 @@ export const homeContent = {
headline: 'DIE JURY BEWERTET GERADE.',
body:
'Die Anmeldephase 2026 ist geschlossen. Unsere unabhängige Expertenjury aus Wissenschaft und Praxis sichtet und bewertet derzeit alle Einreichungen.',
cta: null,
cta: {},
accent: '#4A8FC9',
bg: '#060D1C',
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 phaseRecord = phase as StatusPhase
const fallbackPhase = homeContent.status.phases[i]
const fallbackCta = fallbackPhase?.cta
const fallbackCta = fallbackPhase?.cta?.label
? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url }
: null
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 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 ExpertiseRow = (typeof netzwerkContent.expertise.rows)[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 partners = fallbackArray<Partner>(partnerSection.items, netzwerkContent.partners.items as Partner[]).map((partner) => ({
...partner,
id: partner.stableId || partner.id,
logoColor: fallbackText(partner.logoColor, defaultLogoColor),
}));
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);