diff --git a/package.json b/package.json index 54e8e3b..8448f37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/collections/Pages/datenschutzFields.ts b/src/collections/Pages/datenschutzFields.ts index 8d80d5d..d173ed5 100644 --- a/src/collections/Pages/datenschutzFields.ts +++ b/src/collections/Pages/datenschutzFields.ts @@ -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'), { diff --git a/src/collections/Pages/homeFields.ts b/src/collections/Pages/homeFields.ts index 5e1a905..e2d7acb 100644 --- a/src/collections/Pages/homeFields.ts +++ b/src/collections/Pages/homeFields.ts @@ -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), diff --git a/src/collections/Pages/impressumFields.ts b/src/collections/Pages/impressumFields.ts index 199ad19..f4e6c24 100644 --- a/src/collections/Pages/impressumFields.ts +++ b/src/collections/Pages/impressumFields.ts @@ -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'), { diff --git a/src/collections/Pages/mitgliedWerdenFields.ts b/src/collections/Pages/mitgliedWerdenFields.ts index f170e9d..f57fdb3 100644 --- a/src/collections/Pages/mitgliedWerdenFields.ts +++ b/src/collections/Pages/mitgliedWerdenFields.ts @@ -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'), diff --git a/src/collections/Pages/netzwerkFields.ts b/src/collections/Pages/netzwerkFields.ts index 609562b..acc53d8 100644 --- a/src/collections/Pages/netzwerkFields.ts +++ b/src/collections/Pages/netzwerkFields.ts @@ -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, }, ], diff --git a/src/payload-types.ts b/src/payload-types.ts index a2f5760..2487358 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 { sections?: | T | { - id?: T; + anchorId?: T; tocLabel?: T; title?: T; items?: T; + id?: T; }; }; datenschutz?: @@ -4331,10 +4336,11 @@ export interface PagesSelect { sections?: | T | { - id?: T; + anchorId?: T; tocLabel?: T; title?: T; items?: T; + id?: T; }; }; preistraegerIndex?: @@ -4703,7 +4709,7 @@ export interface PagesSelect { items?: | T | { - id?: T; + stableId?: T; name?: T; role?: T; tier?: T; @@ -4718,6 +4724,7 @@ export interface PagesSelect { website?: T; linkedin?: T; heroImg?: T; + id?: T; }; }; sponsoring?: @@ -4887,7 +4894,7 @@ export interface PagesSelect { items?: | T | { - id?: T; + stableId?: T; number?: T; eyebrow?: T; title?: T; @@ -4895,6 +4902,7 @@ export interface PagesSelect { href?: T; file?: T; download?: T; + id?: T; }; }; formIntro?: diff --git a/src/scripts/mediaByFilename.ts b/src/scripts/mediaByFilename.ts new file mode 100644 index 0000000..80a0b11 --- /dev/null +++ b/src/scripts/mediaByFilename.ts @@ -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 +} diff --git a/src/scripts/migrate-spa-media.ts b/src/scripts/migrate-spa-media.ts index d1949c9..5ed0fdc 100644 --- a/src/scripts/migrate-spa-media.ts +++ b/src/scripts/migrate-spa-media.ts @@ -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() { diff --git a/src/scripts/migrate-spa-pages.ts b/src/scripts/migrate-spa-pages.ts index e89be09..6ef6a74 100644 --- a/src/scripts/migrate-spa-pages.ts +++ b/src/scripts/migrate-spa-pages.ts @@ -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> 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 { ]) 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) { - await upsertPage(payload, page, mediaMap) + 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.') diff --git a/src/scripts/preload-about-page-cms.ts b/src/scripts/preload-about-page-cms.ts index 99de9b4..28b4b4b 100644 --- a/src/scripts/preload-about-page-cms.ts +++ b/src/scripts/preload-about-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-blog-detail-cms.ts b/src/scripts/preload-blog-detail-cms.ts index 044bbee..bb3c0ce 100644 --- a/src/scripts/preload-blog-detail-cms.ts +++ b/src/scripts/preload-blog-detail-cms.ts @@ -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) => { diff --git a/src/scripts/preload-contact-page-cms.ts b/src/scripts/preload-contact-page-cms.ts index f447dbd..56b033b 100644 --- a/src/scripts/preload-contact-page-cms.ts +++ b/src/scripts/preload-contact-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-datenschutz-page-cms.ts b/src/scripts/preload-datenschutz-page-cms.ts index e9ed87a..2b28d96 100644 --- a/src/scripts/preload-datenschutz-page-cms.ts +++ b/src/scripts/preload-datenschutz-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-event-detail-cms.ts b/src/scripts/preload-event-detail-cms.ts index 4b2fe46..924a54a 100644 --- a/src/scripts/preload-event-detail-cms.ts +++ b/src/scripts/preload-event-detail-cms.ts @@ -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) => { diff --git a/src/scripts/preload-formular-upload-page-cms.ts b/src/scripts/preload-formular-upload-page-cms.ts index db55689..493130a 100644 --- a/src/scripts/preload-formular-upload-page-cms.ts +++ b/src/scripts/preload-formular-upload-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-globals-cms.ts b/src/scripts/preload-globals-cms.ts new file mode 100644 index 0000000..4b597ba --- /dev/null +++ b/src/scripts/preload-globals-cms.ts @@ -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) +}) diff --git a/src/scripts/preload-home-page-cms.ts b/src/scripts/preload-home-page-cms.ts index 46e3193..cb3f676 100644 --- a/src/scripts/preload-home-page-cms.ts +++ b/src/scripts/preload-home-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-impressum-page-cms.ts b/src/scripts/preload-impressum-page-cms.ts index 938e50f..4f9d9dd 100644 --- a/src/scripts/preload-impressum-page-cms.ts +++ b/src/scripts/preload-impressum-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-mitglied-werden-page-cms.ts b/src/scripts/preload-mitglied-werden-page-cms.ts index a5e6579..eeee1b0 100644 --- a/src/scripts/preload-mitglied-werden-page-cms.ts +++ b/src/scripts/preload-mitglied-werden-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-netzwerk-page-cms.ts b/src/scripts/preload-netzwerk-page-cms.ts index c0ac4a8..4264fa2 100644 --- a/src/scripts/preload-netzwerk-page-cms.ts +++ b/src/scripts/preload-netzwerk-page-cms.ts @@ -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 (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) => { diff --git a/src/scripts/preload-participation-page-cms.ts b/src/scripts/preload-participation-page-cms.ts index 405c8a5..88c64ac 100644 --- a/src/scripts/preload-participation-page-cms.ts +++ b/src/scripts/preload-participation-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-preistraeger-detail-cms.ts b/src/scripts/preload-preistraeger-detail-cms.ts index fd6db1b..e86f755 100644 --- a/src/scripts/preload-preistraeger-detail-cms.ts +++ b/src/scripts/preload-preistraeger-detail-cms.ts @@ -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) => { diff --git a/src/scripts/preload-preistraeger-index-page-cms.ts b/src/scripts/preload-preistraeger-index-page-cms.ts index 9371510..b07321b 100644 --- a/src/scripts/preload-preistraeger-index-page-cms.ts +++ b/src/scripts/preload-preistraeger-index-page-cms.ts @@ -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) => { diff --git a/src/scripts/preload-press-index-page-cms.ts b/src/scripts/preload-press-index-page-cms.ts index 69e8be3..955f199 100644 --- a/src/scripts/preload-press-index-page-cms.ts +++ b/src/scripts/preload-press-index-page-cms.ts @@ -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 ( @@ -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) => { diff --git a/src/scripts/setup-fresh-database.ts b/src/scripts/setup-fresh-database.ts new file mode 100644 index 0000000..12314a7 --- /dev/null +++ b/src/scripts/setup-fresh-database.ts @@ -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) +}) diff --git a/src/spa/components/legal/LegalPage.tsx b/src/spa/components/legal/LegalPage.tsx index 4150047..b24bc64 100644 --- a/src/spa/components/legal/LegalPage.tsx +++ b/src/spa/components/legal/LegalPage.tsx @@ -29,6 +29,9 @@ export type LegalPageContent = { } type LegalPageCms = Partial +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(cms.sections, content.sections) + const sections = fallbackArray(cms.sections, content.sections).map((section) => ({ + ...section, + id: section.anchorId || section.id, + })) return (
diff --git a/src/spa/homeContent.ts b/src/spa/homeContent.ts index 82300cd..24109a6 100644 --- a/src/spa/homeContent.ts +++ b/src/spa/homeContent.ts @@ -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)', diff --git a/src/spa/pages/Home.tsx b/src/spa/pages/Home.tsx index d3b59be..90195c5 100644 --- a/src/spa/pages/Home.tsx +++ b/src/spa/pages/Home.tsx @@ -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 diff --git a/src/spa/pages/Netzwerk.tsx b/src/spa/pages/Netzwerk.tsx index 8ee511c..b6a55b0 100644 --- a/src/spa/pages/Netzwerk.tsx +++ b/src/spa/pages/Netzwerk.tsx @@ -30,7 +30,7 @@ type NetzwerkCms = Partial & { 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(partnerSection.items, netzwerkContent.partners.items as Partner[]).map((partner) => ({ ...partner, + id: partner.stableId || partner.id, logoColor: fallbackText(partner.logoColor, defaultLogoColor), })); const partnerTiers = fallbackArray(partnerSection.tiers, netzwerkContent.partners.tiers);