chore: migrate pages and media to be cms controlled.
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint . --fix",
|
||||
"migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts",
|
||||
"migrate:spa-pages": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-pages.ts",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { defaultSpaMediaSources } from '@/spa/cmsMedia'
|
||||
|
||||
export const SpaMedia: GlobalConfig = {
|
||||
slug: 'spa-media',
|
||||
label: 'SPA Media',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
admin: {
|
||||
description: 'Central media map for legacy /spa images and videos. The migration script stores each original URL/path here and points it to a Payload media entry.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'assets',
|
||||
type: 'array',
|
||||
labels: {
|
||||
singular: 'Asset',
|
||||
plural: 'Assets',
|
||||
},
|
||||
defaultValue: defaultSpaMediaSources.map((source) => ({ source })),
|
||||
fields: [
|
||||
{
|
||||
name: 'source',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Original /spa image/video path or external URL, e.g. /images/gala-dinner.jpg.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'media',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { draftMode } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import NextApp from '@/spa/NextApp'
|
||||
import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
||||
import { normalizeSpaMediaMap } from '@/spa/cmsMedia'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -11,22 +13,90 @@ type Args = {
|
||||
params: Promise<{ slug?: string[] }>
|
||||
}
|
||||
|
||||
type RouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
|
||||
|
||||
function routeTarget(pathname: string): { collection: RouteCollection; slug: string } {
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (parts[0] === 'preistraeger' && parts[1]) {
|
||||
return { collection: 'preistraeger', slug: parts[1] }
|
||||
}
|
||||
|
||||
if (parts[0] === 'presse' && parts[1] === 'events' && parts[2]) {
|
||||
return { collection: 'events', slug: parts[2] }
|
||||
}
|
||||
|
||||
if (parts[0] === 'presse' && parts[1] === 'blog' && parts[2]) {
|
||||
return { collection: 'posts', slug: parts[2] }
|
||||
}
|
||||
|
||||
return { collection: 'pages', slug: pathname === '/' ? 'startseite' : parts.join('-') }
|
||||
}
|
||||
|
||||
async function findRouteDoc(payload: Awaited<ReturnType<typeof getPayload>>, pathname: string, draft: boolean) {
|
||||
const target = routeTarget(pathname)
|
||||
const result = await payload.find({
|
||||
collection: target.collection as any,
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
where: {
|
||||
or: [
|
||||
{ spaPath: { equals: pathname } },
|
||||
{ slug: { equals: target.slug } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const doc = result.docs[0]
|
||||
return doc ? { collection: target.collection, doc } : undefined
|
||||
}
|
||||
|
||||
async function findList(payload: Awaited<ReturnType<typeof getPayload>>, collection: RouteCollection, draft: boolean) {
|
||||
const result = await payload.find({
|
||||
collection: collection as any,
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
limit: 300,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
return result.docs
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Args) {
|
||||
const { slug = [] } = await params
|
||||
const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/'
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const [header, footer, spaMedia] = await Promise.all([
|
||||
const { isEnabled: draft } = await draftMode()
|
||||
const [header, footer, route, preistraeger, events, posts] = await Promise.all([
|
||||
payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined),
|
||||
payload.findGlobal({ slug: 'spa-media', depth: 1 }).catch(() => undefined),
|
||||
findRouteDoc(payload, pathname, draft),
|
||||
findList(payload, 'preistraeger', draft).catch(() => []),
|
||||
findList(payload, 'events', draft).catch(() => []),
|
||||
findList(payload, 'posts', draft).catch(() => []),
|
||||
])
|
||||
|
||||
if (!route) notFound()
|
||||
|
||||
return (
|
||||
<NextApp
|
||||
pathname={pathname}
|
||||
header={normalizeHeaderData(header)}
|
||||
footer={normalizeFooterData(footer)}
|
||||
media={normalizeSpaMediaMap(spaMedia)}
|
||||
cmsRoute={{
|
||||
collection: route.collection,
|
||||
doc: route.doc as any,
|
||||
lists: {
|
||||
preistraeger: preistraeger as any,
|
||||
events: events as any,
|
||||
posts: posts as any,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
170
src/collections/Events.ts
Normal file
170
src/collections/Events.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { authenticated } from '@/access/authenticated'
|
||||
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
|
||||
import { populatePublishedAt } from '@/hooks/populatePublishedAt'
|
||||
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
|
||||
export const Events: CollectionConfig = {
|
||||
slug: 'events',
|
||||
labels: {
|
||||
singular: 'Event',
|
||||
plural: 'Events',
|
||||
},
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticatedOrPublished,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['title', 'spaPath', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.spaPath || data?.slug,
|
||||
collection: 'events' as any,
|
||||
req,
|
||||
}),
|
||||
},
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: ((data?.spaPath as string | undefined) || (data?.slug as string)),
|
||||
collection: 'events' as any,
|
||||
req,
|
||||
}),
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'longDescription',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'time',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'venue',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Geplant', value: 'geplant' },
|
||||
{ label: 'Anmeldung offen', value: 'anmeldung-offen' },
|
||||
{ label: 'Intern', value: 'intern' },
|
||||
{ label: 'Abgeschlossen', value: 'abgeschlossen' },
|
||||
],
|
||||
defaultValue: 'geplant',
|
||||
},
|
||||
{
|
||||
name: 'eckdaten',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'value', type: 'text', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'updates',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'timestamp', type: 'text', required: true },
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: ['ankündigung', 'erinnerung', 'update', 'highlight', 'ergebnis', 'wichtig'],
|
||||
required: true,
|
||||
},
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'body', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'spaPath',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Route in the legacy SPA used for previewing the current migrated event state.',
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
MetaTitleField({ hasGenerateFn: true }),
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
hasGenerateFn: true,
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
slugField(),
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [populatePublishedAt],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 100,
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
@@ -37,18 +37,18 @@ export const Pages: CollectionConfig<'pages'> = {
|
||||
slug: true,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
defaultColumns: ['title', 'spaPath', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug,
|
||||
slug: data?.spaPath || data?.slug,
|
||||
collection: 'pages',
|
||||
req,
|
||||
}),
|
||||
},
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug as string,
|
||||
slug: ((data?.spaPath as string | undefined) || (data?.slug as string)),
|
||||
collection: 'pages',
|
||||
req,
|
||||
}),
|
||||
@@ -117,6 +117,15 @@ export const Pages: CollectionConfig<'pages'> = {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'spaPath',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Route in the legacy SPA used for previewing the current migrated page state.',
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
slugField(),
|
||||
],
|
||||
hooks: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = `/posts/${doc.slug}`
|
||||
const path = doc.spaPath || `/posts/${doc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating post at path: ${path}`)
|
||||
|
||||
@@ -21,7 +21,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
||||
|
||||
// If the post was previously published, we need to revalidate the old path
|
||||
if (previousDoc._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = `/posts/${previousDoc.slug}`
|
||||
const oldPath = previousDoc.spaPath || `/posts/${previousDoc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
|
||||
|
||||
@@ -34,7 +34,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = `/posts/${doc?.slug}`
|
||||
const path = doc?.spaPath || `/posts/${doc?.slug}`
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('posts-sitemap', 'max')
|
||||
|
||||
@@ -29,6 +29,10 @@ import { slugField } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig<'posts'> = {
|
||||
slug: 'posts',
|
||||
labels: {
|
||||
singular: 'Presse',
|
||||
plural: 'Presse',
|
||||
},
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
@@ -48,18 +52,18 @@ export const Posts: CollectionConfig<'posts'> = {
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
defaultColumns: ['title', 'spaPath', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug,
|
||||
slug: data?.spaPath || data?.slug,
|
||||
collection: 'posts',
|
||||
req,
|
||||
}),
|
||||
},
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug as string,
|
||||
slug: ((data?.spaPath as string | undefined) || (data?.slug as string)),
|
||||
collection: 'posts',
|
||||
req,
|
||||
}),
|
||||
@@ -81,6 +85,49 @@ export const Posts: CollectionConfig<'posts'> = {
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'cat',
|
||||
type: 'text',
|
||||
label: 'Category',
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'authorName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'authorRole',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'readTime',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'sections',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'heading', type: 'text' },
|
||||
{ name: 'body', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
fields: [{ name: 'tag', type: 'text', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'relatedSlugs',
|
||||
type: 'array',
|
||||
fields: [{ name: 'slug', type: 'text', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
@@ -161,6 +208,15 @@ export const Posts: CollectionConfig<'posts'> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'spaPath',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Route in the legacy SPA used for previewing the current migrated press article state.',
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
|
||||
160
src/collections/Preistraeger.ts
Normal file
160
src/collections/Preistraeger.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { authenticated } from '@/access/authenticated'
|
||||
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
|
||||
import { populatePublishedAt } from '@/hooks/populatePublishedAt'
|
||||
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
|
||||
export const Preistraeger: CollectionConfig = {
|
||||
slug: 'preistraeger',
|
||||
labels: {
|
||||
singular: 'Preisträger',
|
||||
plural: 'Preisträger',
|
||||
},
|
||||
access: {
|
||||
create: authenticated,
|
||||
delete: authenticated,
|
||||
read: authenticatedOrPublished,
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['title', 'spaPath', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.spaPath || data?.slug,
|
||||
collection: 'preistraeger' as any,
|
||||
req,
|
||||
}),
|
||||
},
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: ((data?.spaPath as string | undefined) || (data?.slug as string)),
|
||||
collection: 'preistraeger' as any,
|
||||
req,
|
||||
}),
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'awardType',
|
||||
type: 'text',
|
||||
label: 'Auszeichnung',
|
||||
},
|
||||
{
|
||||
name: 'shortDesc',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'longDesc',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Fallback description. Prefer Kurzbeschreibung / Langbeschreibung for migrated Preisträger content.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'quote',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'quotePerson',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'quoteRole',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'hasMedia',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
name: 'spaPath',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Route in the legacy SPA used for previewing the current migrated Preisträger state.',
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
MetaTitleField({ hasGenerateFn: true }),
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
hasGenerateFn: true,
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
slugField(),
|
||||
],
|
||||
hooks: {
|
||||
beforeChange: [populatePublishedAt],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 100,
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
@@ -69,6 +69,8 @@ export interface Config {
|
||||
collections: {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
events: Event;
|
||||
preistraeger: Preistraeger;
|
||||
media: Media;
|
||||
categories: Category;
|
||||
users: User;
|
||||
@@ -91,6 +93,8 @@ export interface Config {
|
||||
collectionsSelect: {
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
events: EventsSelect<false> | EventsSelect<true>;
|
||||
preistraeger: PreistraegerSelect<false> | PreistraegerSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
@@ -112,12 +116,10 @@ export interface Config {
|
||||
globals: {
|
||||
header: Header;
|
||||
footer: Footer;
|
||||
'spa-media': SpaMedia;
|
||||
};
|
||||
globalsSelect: {
|
||||
header: HeaderSelect<false> | HeaderSelect<true>;
|
||||
footer: FooterSelect<false> | FooterSelect<true>;
|
||||
'spa-media': SpaMediaSelect<false> | SpaMediaSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
widgets: {
|
||||
@@ -213,6 +215,10 @@ export interface Page {
|
||||
description?: string | null;
|
||||
};
|
||||
publishedAt?: string | null;
|
||||
/**
|
||||
* Route in the legacy SPA used for previewing the current migrated page state.
|
||||
*/
|
||||
spaPath?: string | null;
|
||||
/**
|
||||
* When enabled, the slug will auto-generate from the title field on save and autosave.
|
||||
*/
|
||||
@@ -230,6 +236,31 @@ export interface Post {
|
||||
id: number;
|
||||
title: string;
|
||||
heroImage?: (number | null) | Media;
|
||||
excerpt?: string | null;
|
||||
cat?: string | null;
|
||||
date?: string | null;
|
||||
authorName?: string | null;
|
||||
authorRole?: string | null;
|
||||
readTime?: string | null;
|
||||
sections?:
|
||||
| {
|
||||
heading?: string | null;
|
||||
body: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
tags?:
|
||||
| {
|
||||
tag: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
relatedSlugs?:
|
||||
| {
|
||||
slug: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
@@ -255,6 +286,10 @@ export interface Post {
|
||||
image?: (number | null) | Media;
|
||||
description?: string | null;
|
||||
};
|
||||
/**
|
||||
* Route in the legacy SPA used for previewing the current migrated press article state.
|
||||
*/
|
||||
spaPath?: string | null;
|
||||
publishedAt?: string | null;
|
||||
authors?: (number | User)[] | null;
|
||||
populatedAuthors?:
|
||||
@@ -783,6 +818,99 @@ export interface Form {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "events".
|
||||
*/
|
||||
export interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
image?: (number | null) | Media;
|
||||
description?: string | null;
|
||||
longDescription?: string | null;
|
||||
date?: string | null;
|
||||
time?: string | null;
|
||||
location?: string | null;
|
||||
venue?: string | null;
|
||||
category?: string | null;
|
||||
status?: ('geplant' | 'anmeldung-offen' | 'intern' | 'abgeschlossen') | null;
|
||||
eckdaten?:
|
||||
| {
|
||||
label: string;
|
||||
value: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updates?:
|
||||
| {
|
||||
timestamp: string;
|
||||
type: 'ankündigung' | 'erinnerung' | 'update' | 'highlight' | 'ergebnis' | 'wichtig';
|
||||
title: string;
|
||||
body: string;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
/**
|
||||
* Route in the legacy SPA used for previewing the current migrated event state.
|
||||
*/
|
||||
spaPath?: string | null;
|
||||
publishedAt?: string | null;
|
||||
meta?: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
/**
|
||||
* When enabled, the slug will auto-generate from the title field on save and autosave.
|
||||
*/
|
||||
generateSlug?: boolean | null;
|
||||
slug: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "preistraeger".
|
||||
*/
|
||||
export interface Preistraeger {
|
||||
id: number;
|
||||
title: string;
|
||||
image?: (number | null) | Media;
|
||||
category?: string | null;
|
||||
year?: number | null;
|
||||
awardType?: string | null;
|
||||
shortDesc?: string | null;
|
||||
longDesc?: string | null;
|
||||
/**
|
||||
* Fallback description. Prefer Kurzbeschreibung / Langbeschreibung for migrated Preisträger content.
|
||||
*/
|
||||
description?: string | null;
|
||||
quote?: string | null;
|
||||
quotePerson?: string | null;
|
||||
quoteRole?: string | null;
|
||||
location?: string | null;
|
||||
industry?: string | null;
|
||||
website?: string | null;
|
||||
hasMedia?: boolean | null;
|
||||
/**
|
||||
* Route in the legacy SPA used for previewing the current migrated Preisträger state.
|
||||
*/
|
||||
spaPath?: string | null;
|
||||
publishedAt?: string | null;
|
||||
meta?: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
/**
|
||||
* When enabled, the slug will auto-generate from the title field on save and autosave.
|
||||
*/
|
||||
generateSlug?: boolean | null;
|
||||
slug: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "redirects".
|
||||
@@ -981,6 +1109,14 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'posts';
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'events';
|
||||
value: number | Event;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'preistraeger';
|
||||
value: number | Preistraeger;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
@@ -1100,6 +1236,7 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
description?: T;
|
||||
};
|
||||
publishedAt?: T;
|
||||
spaPath?: T;
|
||||
generateSlug?: T;
|
||||
slug?: T;
|
||||
updatedAt?: T;
|
||||
@@ -1197,6 +1334,31 @@ export interface FormBlockSelect<T extends boolean = true> {
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
heroImage?: T;
|
||||
excerpt?: T;
|
||||
cat?: T;
|
||||
date?: T;
|
||||
authorName?: T;
|
||||
authorRole?: T;
|
||||
readTime?: T;
|
||||
sections?:
|
||||
| T
|
||||
| {
|
||||
heading?: T;
|
||||
body?: T;
|
||||
id?: T;
|
||||
};
|
||||
tags?:
|
||||
| T
|
||||
| {
|
||||
tag?: T;
|
||||
id?: T;
|
||||
};
|
||||
relatedSlugs?:
|
||||
| T
|
||||
| {
|
||||
slug?: T;
|
||||
id?: T;
|
||||
};
|
||||
content?: T;
|
||||
relatedPosts?: T;
|
||||
categories?: T;
|
||||
@@ -1207,6 +1369,7 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
image?: T;
|
||||
description?: T;
|
||||
};
|
||||
spaPath?: T;
|
||||
publishedAt?: T;
|
||||
authors?: T;
|
||||
populatedAuthors?:
|
||||
@@ -1221,6 +1384,86 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "events_select".
|
||||
*/
|
||||
export interface EventsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
subtitle?: T;
|
||||
image?: T;
|
||||
description?: T;
|
||||
longDescription?: T;
|
||||
date?: T;
|
||||
time?: T;
|
||||
location?: T;
|
||||
venue?: T;
|
||||
category?: T;
|
||||
status?: T;
|
||||
eckdaten?:
|
||||
| T
|
||||
| {
|
||||
label?: T;
|
||||
value?: T;
|
||||
id?: T;
|
||||
};
|
||||
updates?:
|
||||
| T
|
||||
| {
|
||||
timestamp?: T;
|
||||
type?: T;
|
||||
title?: T;
|
||||
body?: T;
|
||||
id?: T;
|
||||
};
|
||||
spaPath?: T;
|
||||
publishedAt?: T;
|
||||
meta?:
|
||||
| T
|
||||
| {
|
||||
title?: T;
|
||||
description?: T;
|
||||
};
|
||||
generateSlug?: T;
|
||||
slug?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "preistraeger_select".
|
||||
*/
|
||||
export interface PreistraegerSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
image?: T;
|
||||
category?: T;
|
||||
year?: T;
|
||||
awardType?: T;
|
||||
shortDesc?: T;
|
||||
longDesc?: T;
|
||||
description?: T;
|
||||
quote?: T;
|
||||
quotePerson?: T;
|
||||
quoteRole?: T;
|
||||
location?: T;
|
||||
industry?: T;
|
||||
website?: T;
|
||||
hasMedia?: T;
|
||||
spaPath?: T;
|
||||
publishedAt?: T;
|
||||
meta?:
|
||||
| T
|
||||
| {
|
||||
title?: T;
|
||||
description?: T;
|
||||
};
|
||||
generateSlug?: T;
|
||||
slug?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
@@ -1758,27 +2001,6 @@ export interface Footer {
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* Central media map for legacy /spa images and videos. The migration script stores each original URL/path here and points it to a Payload media entry.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "spa-media".
|
||||
*/
|
||||
export interface SpaMedia {
|
||||
id: number;
|
||||
assets?:
|
||||
| {
|
||||
/**
|
||||
* Original /spa image/video path or external URL, e.g. /images/gala-dinner.jpg.
|
||||
*/
|
||||
source: string;
|
||||
media: number | Media;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "header_select".
|
||||
@@ -1897,22 +2119,6 @@ export interface FooterSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "spa-media_select".
|
||||
*/
|
||||
export interface SpaMediaSelect<T extends boolean = true> {
|
||||
assets?:
|
||||
| T
|
||||
| {
|
||||
source?: T;
|
||||
media?: T;
|
||||
id?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
@@ -1939,6 +2145,14 @@ export interface TaskSchedulePublish {
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: number | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'events';
|
||||
value: number | Event;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'preistraeger';
|
||||
value: number | Preistraeger;
|
||||
} | null);
|
||||
global?: string | null;
|
||||
user?: (number | null) | User;
|
||||
|
||||
@@ -5,13 +5,14 @@ import { buildConfig, PayloadRequest } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Categories } from './collections/Categories'
|
||||
import { Events } from './collections/Events'
|
||||
import { Media } from './collections/Media'
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Posts } from './collections/Posts'
|
||||
import { Preistraeger } from './collections/Preistraeger'
|
||||
import { Users } from './collections/Users'
|
||||
import { Footer } from './Footer/config'
|
||||
import { Header } from './Header/config'
|
||||
import { SpaMedia } from './SpaMedia/config'
|
||||
import { plugins } from './plugins'
|
||||
import { defaultLexical } from '@/fields/defaultLexical'
|
||||
import { getServerSideURL } from './utilities/getURL'
|
||||
@@ -63,9 +64,9 @@ export default buildConfig({
|
||||
url: process.env.DATABASE_URL || '',
|
||||
},
|
||||
}),
|
||||
collections: [Pages, Posts, Media, Categories, Users],
|
||||
collections: [Pages, Posts, Events, Preistraeger, Media, Categories, Users],
|
||||
cors: [getServerSideURL()].filter(Boolean),
|
||||
globals: [Header, Footer, SpaMedia],
|
||||
globals: [Header, Footer],
|
||||
plugins,
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
sharp,
|
||||
|
||||
@@ -164,13 +164,6 @@ async function main() {
|
||||
}
|
||||
|
||||
const bySource = new Map(assets.map((asset) => [asset.source, asset]))
|
||||
|
||||
await payload.updateGlobal({
|
||||
slug: 'spa-media' as any,
|
||||
data: { assets: [...bySource.values()] } as any,
|
||||
context: { disableRevalidate: true },
|
||||
})
|
||||
|
||||
const assetId = (source: string) => bySource.get(source)?.media
|
||||
await Promise.all([
|
||||
assetId('/bmp-logo.png')
|
||||
|
||||
304
src/scripts/migrate-spa-pages.ts
Normal file
304
src/scripts/migrate-spa-pages.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import config from '@payload-config'
|
||||
import crypto from 'crypto'
|
||||
import path from 'path'
|
||||
import 'dotenv/config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { articles } from '@/spa/data/blog'
|
||||
import { events } from '@/spa/data/events'
|
||||
import { WINNERS } from '@/spa/data/winners'
|
||||
import { spaPages, type SpaPageRegistryEntry } from '@/spa/pageRegistry'
|
||||
|
||||
type Payload = Awaited<ReturnType<typeof getPayload>>
|
||||
type TargetCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
|
||||
|
||||
const emptyRichText = (text: string) => ({
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
})
|
||||
|
||||
function targetForPage(page: SpaPageRegistryEntry): TargetCollection {
|
||||
if (page.source === 'winner') return 'preistraeger'
|
||||
if (page.source === 'event') return 'events'
|
||||
if (page.source === 'blog') return 'posts'
|
||||
return 'pages'
|
||||
}
|
||||
|
||||
function targetSlug(page: SpaPageRegistryEntry) {
|
||||
if (page.source === 'winner' || page.source === 'event' || page.source === 'blog') {
|
||||
return page.path.split('/').filter(Boolean).at(-1) || page.slug
|
||||
}
|
||||
return page.slug
|
||||
}
|
||||
|
||||
function legacySanitizedSlug(page: SpaPageRegistryEntry) {
|
||||
if (page.path === '/') return 'startseite'
|
||||
return page.path.replace(/^\//, '').replace(/\//g, '')
|
||||
}
|
||||
|
||||
type MediaMap = Map<string, number | string>
|
||||
|
||||
function safeRemoteName(url: string) {
|
||||
const parsed = new URL(url)
|
||||
const ext = path.extname(parsed.pathname) || '.jpg'
|
||||
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 10)
|
||||
return `${parsed.hostname.replace(/[^a-z0-9]+/gi, '-')}-${hash}${ext}`.toLowerCase()
|
||||
}
|
||||
|
||||
function filenameForSource(source: string) {
|
||||
return source.startsWith('http') ? safeRemoteName(source) : path.basename(source.split('?')[0])
|
||||
}
|
||||
|
||||
function mediaID(mediaMap: MediaMap, source?: string) {
|
||||
return source ? mediaMap.get(source) : undefined
|
||||
}
|
||||
|
||||
function baseData(page: SpaPageRegistryEntry) {
|
||||
return {
|
||||
title: page.title,
|
||||
slug: targetSlug(page),
|
||||
spaPath: page.path,
|
||||
_status: 'published' as const,
|
||||
meta: {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
},
|
||||
publishedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
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.`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function postData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
|
||||
const article = articles.find((item) => item.slug === targetSlug(page))
|
||||
return {
|
||||
...baseData(page),
|
||||
heroImage: mediaID(mediaMap, article?.img),
|
||||
excerpt: article?.excerpt || page.description,
|
||||
cat: article?.cat,
|
||||
date: article?.date,
|
||||
authorName: article?.author,
|
||||
authorRole: article?.authorRole,
|
||||
readTime: article?.readTime,
|
||||
sections: article?.sections || [],
|
||||
tags: article?.tags?.map((tag) => ({ tag })) || [],
|
||||
relatedSlugs: article?.relatedSlugs?.map((slug) => ({ slug })) || [],
|
||||
content: emptyRichText(
|
||||
article?.sections?.map((section) => [section.heading, section.body].filter(Boolean).join('\n')).join('\n\n') ||
|
||||
`Dieser Presseartikel wird aktuell noch von der migrierten SPA gerendert. Über „Preview“ kann der aktuelle Stand unter ${page.path} betrachtet werden.`,
|
||||
),
|
||||
relatedPosts: [],
|
||||
categories: [],
|
||||
authors: [],
|
||||
}
|
||||
}
|
||||
|
||||
function eventData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
|
||||
const event = events.find((item) => item.slug === targetSlug(page))
|
||||
return {
|
||||
...baseData(page),
|
||||
subtitle: event?.subtitle,
|
||||
image: mediaID(mediaMap, event?.img),
|
||||
description: event?.description || page.description,
|
||||
longDescription: event?.longDescription,
|
||||
date: event?.date,
|
||||
time: event?.time,
|
||||
location: event?.location,
|
||||
venue: event?.venue,
|
||||
category: event?.category,
|
||||
status: event?.status || 'geplant',
|
||||
eckdaten: event?.eckdaten || [],
|
||||
updates: event?.updates?.map(({ id: _id, ...update }) => update) || [],
|
||||
}
|
||||
}
|
||||
|
||||
function preistraegerData(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
|
||||
const winner = WINNERS.find((item) => item.slug === targetSlug(page))
|
||||
return {
|
||||
...baseData(page),
|
||||
image: mediaID(mediaMap, winner?.img),
|
||||
category: winner?.category,
|
||||
year: winner?.year,
|
||||
awardType: winner?.type,
|
||||
shortDesc: winner?.shortDesc || page.description,
|
||||
longDesc: winner?.longDesc,
|
||||
description: winner?.shortDesc || page.description,
|
||||
quote: winner?.quote,
|
||||
quotePerson: winner?.quotePerson,
|
||||
quoteRole: winner?.quoteRole,
|
||||
location: winner?.location,
|
||||
industry: winner?.industry,
|
||||
website: winner?.website,
|
||||
hasMedia: winner?.hasMedia,
|
||||
}
|
||||
}
|
||||
|
||||
function dataForPage(page: SpaPageRegistryEntry, mediaMap: MediaMap) {
|
||||
const target = targetForPage(page)
|
||||
if (target === 'pages') return pageData(page)
|
||||
if (target === 'posts') return postData(page, mediaMap)
|
||||
if (target === 'events') return eventData(page, mediaMap)
|
||||
return preistraegerData(page, mediaMap)
|
||||
}
|
||||
|
||||
async function findExistingDoc(payload: Payload, collection: TargetCollection, page: SpaPageRegistryEntry) {
|
||||
const existing = await payload.find({
|
||||
collection: collection as any,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
where: {
|
||||
or: [
|
||||
{
|
||||
spaPath: {
|
||||
equals: page.path,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: targetSlug(page),
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: page.slug,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: legacySanitizedSlug(page),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
return existing.docs[0]
|
||||
}
|
||||
|
||||
async function removeMovedPageDoc(payload: Payload, page: SpaPageRegistryEntry) {
|
||||
const pageDoc = await findExistingDoc(payload, 'pages', page)
|
||||
if (!pageDoc) return
|
||||
|
||||
await payload.delete({
|
||||
collection: 'pages',
|
||||
id: pageDoc.id,
|
||||
depth: 0,
|
||||
context: { disableRevalidate: true },
|
||||
})
|
||||
}
|
||||
|
||||
async function upsertPage(payload: Payload, page: SpaPageRegistryEntry, mediaMap: MediaMap) {
|
||||
const collection = targetForPage(page)
|
||||
const existing = await findExistingDoc(payload, collection, page)
|
||||
const data = dataForPage(page, mediaMap)
|
||||
|
||||
if (existing) {
|
||||
await payload.update({
|
||||
collection: collection as any,
|
||||
id: existing.id,
|
||||
data,
|
||||
depth: 0,
|
||||
context: { disableRevalidate: true },
|
||||
})
|
||||
payload.logger.info(`updated ${collection}: ${page.path}`)
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: collection as any,
|
||||
data,
|
||||
depth: 0,
|
||||
context: { disableRevalidate: true },
|
||||
})
|
||||
payload.logger.info(`created ${collection}: ${page.path}`)
|
||||
}
|
||||
|
||||
if (collection !== 'pages') {
|
||||
await removeMovedPageDoc(payload, page)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMediaMap(payload: Payload): Promise<MediaMap> {
|
||||
const sources = new Set([
|
||||
...WINNERS.map((winner) => winner.img),
|
||||
...events.map((event) => event.img),
|
||||
...articles.map((article) => article.img),
|
||||
])
|
||||
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
|
||||
}),
|
||||
)
|
||||
|
||||
return new Map(assets.filter(Boolean) as [string, number | string][])
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const payload = await getPayload({ config })
|
||||
const mediaMap = await loadMediaMap(payload)
|
||||
payload.logger.info(`Migrating ${spaPages.length} SPA routes into Payload collections...`)
|
||||
|
||||
for (const page of spaPages) {
|
||||
await upsertPage(payload, page, mediaMap)
|
||||
}
|
||||
|
||||
payload.logger.info('SPA page migration complete.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import CmsMediaRewriter from '@/spa/components/CmsMediaRewriter'
|
||||
import Layout from '@/spa/components/layout/Layout'
|
||||
import ScrollToTop from '@/spa/components/layout/ScrollToTop'
|
||||
import type { SpaFooterData, SpaHeaderData } from '@/spa/cmsNavigation'
|
||||
import type { SpaMediaMap } from '@/spa/cmsMedia'
|
||||
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
|
||||
import About from '@/spa/pages/About'
|
||||
import BlogDetail from '@/spa/pages/BlogDetail'
|
||||
import Contact from '@/spa/pages/Contact'
|
||||
import CmsGenericPage from '@/spa/pages/CmsGenericPage'
|
||||
import Datenschutz from '@/spa/pages/Datenschutz'
|
||||
import EventDetail from '@/spa/pages/EventDetail'
|
||||
import FormularUpload from '@/spa/pages/FormularUpload'
|
||||
@@ -22,7 +22,7 @@ import Preistraeger from '@/spa/pages/Preistraeger'
|
||||
import PreistraegerDetail from '@/spa/pages/PreistraegerDetail'
|
||||
import Press from '@/spa/pages/Press'
|
||||
|
||||
const routes: Record<string, React.ComponentType> = {
|
||||
export const routes: Record<string, React.ComponentType> = {
|
||||
'/': Home,
|
||||
'/teilnahme': Participation,
|
||||
'/preistraeger': Preistraeger,
|
||||
@@ -36,24 +36,33 @@ const routes: Record<string, React.ComponentType> = {
|
||||
'/impressum': Impressum,
|
||||
}
|
||||
|
||||
export function isSpaRoute(pathname: string) {
|
||||
return Boolean(
|
||||
routes[pathname] ||
|
||||
pathname.startsWith('/preistraeger/') ||
|
||||
pathname.startsWith('/presse/events/') ||
|
||||
pathname.startsWith('/presse/blog/'),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePage(pathname: string) {
|
||||
if (routes[pathname]) return routes[pathname]
|
||||
if (pathname.startsWith('/preistraeger/')) return PreistraegerDetail
|
||||
if (pathname.startsWith('/presse/events/')) return EventDetail
|
||||
if (pathname.startsWith('/presse/blog/')) return BlogDetail
|
||||
return Home
|
||||
return CmsGenericPage
|
||||
}
|
||||
|
||||
export default function NextApp({
|
||||
pathname,
|
||||
header,
|
||||
footer,
|
||||
media,
|
||||
cmsRoute,
|
||||
}: {
|
||||
pathname: string
|
||||
header?: SpaHeaderData
|
||||
footer?: SpaFooterData
|
||||
media?: SpaMediaMap
|
||||
cmsRoute?: CmsRouteData
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -82,11 +91,12 @@ export default function NextApp({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CmsMediaRewriter media={media} />
|
||||
<ScrollToTop />
|
||||
<Layout header={header} footer={footer}>
|
||||
<Page />
|
||||
</Layout>
|
||||
<CmsRouteProvider value={cmsRoute}>
|
||||
<ScrollToTop />
|
||||
<Layout header={header} footer={footer}>
|
||||
<Page />
|
||||
</Layout>
|
||||
</CmsRouteProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Media } from '@/payload-types'
|
||||
|
||||
export type SpaMediaEntry = {
|
||||
source: string
|
||||
media?: { url?: string } | string | number | Media
|
||||
}
|
||||
|
||||
export type SpaMediaMap = Record<string, string>
|
||||
|
||||
export const defaultSpaMediaSources = [
|
||||
'/bmp-logo.png',
|
||||
'/logo.png',
|
||||
'/gala-preistraeger.jpg',
|
||||
'/munich-skyline.jpg',
|
||||
'/bavarian-flag.jpg',
|
||||
'/rauten.webp',
|
||||
'/website-template-OG.webp',
|
||||
'/images/bmp-banner.jpg',
|
||||
'/images/buehne-gewinner.jpg',
|
||||
'/images/buehne-moderatoren.jpg',
|
||||
'/images/ewif-logo.png',
|
||||
'/images/gala-dinner.jpg',
|
||||
'/images/gala-saal-overview.jpg',
|
||||
'/images/gewinner-gruppenfoto.jpg',
|
||||
'/images/mitglied-hero.jpg',
|
||||
'/images/netzwerk-hero.jpg',
|
||||
'/images/networking-innenhof.jpg',
|
||||
'/images/partner-logos.png',
|
||||
'/images/preisuebergabe.jpg',
|
||||
'/images/preistraeger-award.jpg',
|
||||
'/images/preistraeger-gruppe.jpg',
|
||||
'/images/preistraeger-jubel.jpg',
|
||||
'/images/saal-gedeckt.jpg',
|
||||
'/images/saal-leer-atmosphaere.jpg',
|
||||
'/images/tisch-detail.jpg',
|
||||
]
|
||||
|
||||
const isMedia = (value: unknown): value is Media => Boolean(value && typeof value === 'object' && 'url' in value)
|
||||
|
||||
export function normalizeSpaMediaMap(global: any): SpaMediaMap {
|
||||
const entries: SpaMediaEntry[] = Array.isArray(global?.assets) ? global.assets : []
|
||||
|
||||
return entries.reduce((acc: SpaMediaMap, entry: SpaMediaEntry) => {
|
||||
if (!entry?.source) return acc
|
||||
if (isMedia(entry.media) && entry.media.url) acc[entry.source] = entry.media.url
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
23
src/spa/cmsMediaField.ts
Normal file
23
src/spa/cmsMediaField.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CmsRouteDoc } from './cmsRoute'
|
||||
|
||||
type MediaLike = {
|
||||
url?: string | null
|
||||
filename?: string | null
|
||||
alt?: string | null
|
||||
}
|
||||
|
||||
export function mediaUrl(media: unknown, fallback: string) {
|
||||
if (!media || typeof media !== 'object') return fallback
|
||||
const doc = media as MediaLike
|
||||
return doc.url || (doc.filename ? `/media/${doc.filename}` : fallback)
|
||||
}
|
||||
|
||||
export function mediaAlt(media: unknown, fallback: string) {
|
||||
if (!media || typeof media !== 'object') return fallback
|
||||
const doc = media as MediaLike
|
||||
return doc.alt || fallback
|
||||
}
|
||||
|
||||
export function docImageUrl(doc: CmsRouteDoc | undefined, fallback: string, field = 'image') {
|
||||
return mediaUrl(doc?.[field], fallback)
|
||||
}
|
||||
38
src/spa/cmsRoute.tsx
Normal file
38
src/spa/cmsRoute.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext } from 'react'
|
||||
|
||||
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
|
||||
|
||||
export type CmsRouteDoc = {
|
||||
id: number | string
|
||||
title?: string | null
|
||||
slug?: string | null
|
||||
spaPath?: string | null
|
||||
description?: string | null
|
||||
meta?: {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
} | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type CmsRouteData = {
|
||||
collection: CmsRouteCollection
|
||||
doc: CmsRouteDoc
|
||||
lists?: Partial<Record<CmsRouteCollection, CmsRouteDoc[]>>
|
||||
}
|
||||
|
||||
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
|
||||
|
||||
export function CmsRouteProvider({ children, value }: { children: React.ReactNode; value?: CmsRouteData }) {
|
||||
return <CmsRouteContext.Provider value={value}>{children}</CmsRouteContext.Provider>
|
||||
}
|
||||
|
||||
export function useCmsRoute() {
|
||||
return useContext(CmsRouteContext)
|
||||
}
|
||||
|
||||
export function useCmsCollection(collection: CmsRouteCollection) {
|
||||
return useCmsRoute()?.lists?.[collection] || []
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { SpaMediaMap } from '@/spa/cmsMedia'
|
||||
|
||||
const CSS_URL_RE = /url\((['"]?)(.*?)\1\)/g
|
||||
|
||||
function canonical(value: string) {
|
||||
if (!value) return value
|
||||
try {
|
||||
const url = new URL(value, window.location.origin)
|
||||
return url.origin === window.location.origin ? `${url.pathname}${url.search}` : value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteCssUrls(value: string, media: SpaMediaMap) {
|
||||
return value.replace(CSS_URL_RE, (_match, quote: string, url: string) => {
|
||||
const replacement = media[url] || media[canonical(url)]
|
||||
return replacement ? `url(${quote}${replacement}${quote})` : _match
|
||||
})
|
||||
}
|
||||
|
||||
function rewrite(root: ParentNode, media: SpaMediaMap) {
|
||||
root.querySelectorAll('img, source, video').forEach((node) => {
|
||||
const el = node as HTMLImageElement | HTMLSourceElement | HTMLVideoElement
|
||||
const current = el.getAttribute('src')
|
||||
if (current) {
|
||||
const replacement = media[current] || media[canonical(current)]
|
||||
if (replacement && current !== replacement) el.setAttribute('src', replacement)
|
||||
}
|
||||
|
||||
const poster = el.getAttribute('poster')
|
||||
if (poster) {
|
||||
const replacement = media[poster] || media[canonical(poster)]
|
||||
if (replacement && poster !== replacement) el.setAttribute('poster', replacement)
|
||||
}
|
||||
})
|
||||
|
||||
root.querySelectorAll<HTMLElement>('[style*="url("]').forEach((el) => {
|
||||
const backgroundImage = el.style.backgroundImage
|
||||
if (backgroundImage) {
|
||||
const rewritten = rewriteCssUrls(backgroundImage, media)
|
||||
if (rewritten !== backgroundImage) el.style.backgroundImage = rewritten
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default function CmsMediaRewriter({ media }: { media?: SpaMediaMap }) {
|
||||
useEffect(() => {
|
||||
if (!media || Object.keys(media).length === 0) return
|
||||
|
||||
rewrite(document, media)
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) rewrite(node as Element, media)
|
||||
})
|
||||
if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) {
|
||||
rewrite((mutation.target as Element).parentNode || document, media)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'poster', 'style'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [media])
|
||||
|
||||
return null
|
||||
}
|
||||
60
src/spa/pageRegistry.ts
Normal file
60
src/spa/pageRegistry.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { articles } from './data/blog'
|
||||
import { events } from './data/events'
|
||||
import { WINNERS } from './data/winners'
|
||||
|
||||
export type SpaPageRegistryEntry = {
|
||||
path: string
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
source: 'static' | 'winner' | 'event' | 'blog'
|
||||
}
|
||||
|
||||
const staticPages: SpaPageRegistryEntry[] = [
|
||||
{ path: '/', slug: 'startseite', title: 'Startseite', source: 'static' },
|
||||
{ path: '/teilnahme', slug: 'teilnahme', title: 'Teilnahme', source: 'static' },
|
||||
{ path: '/preistraeger', slug: 'preistraeger', title: 'Preisträger', source: 'static' },
|
||||
{ path: '/der-bmp', slug: 'der-bmp', title: 'Der BMP', source: 'static' },
|
||||
{ path: '/netzwerk', slug: 'netzwerk', title: 'Netzwerk', source: 'static' },
|
||||
{ path: '/presse', slug: 'presse', title: 'Presse', source: 'static' },
|
||||
{ path: '/kontakt', slug: 'kontakt', title: 'Kontakt', source: 'static' },
|
||||
{
|
||||
path: '/formular-hochladen',
|
||||
slug: 'formular-hochladen',
|
||||
title: 'Formular hochladen',
|
||||
source: 'static',
|
||||
},
|
||||
{
|
||||
path: '/mitglied-werden',
|
||||
slug: 'mitglied-werden',
|
||||
title: 'Mitglied werden',
|
||||
source: 'static',
|
||||
},
|
||||
{ path: '/datenschutz', slug: 'datenschutz', title: 'Datenschutz', source: 'static' },
|
||||
{ path: '/impressum', slug: 'impressum', title: 'Impressum', source: 'static' },
|
||||
]
|
||||
|
||||
export const spaPages: SpaPageRegistryEntry[] = [
|
||||
...staticPages,
|
||||
...WINNERS.map((winner) => ({
|
||||
path: `/preistraeger/${winner.slug}`,
|
||||
slug: `preistraeger-${winner.slug}`,
|
||||
title: winner.name,
|
||||
description: winner.shortDesc,
|
||||
source: 'winner' as const,
|
||||
})),
|
||||
...events.map((event) => ({
|
||||
path: `/presse/events/${event.slug}`,
|
||||
slug: `presse-events-${event.slug}`,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
source: 'event' as const,
|
||||
})),
|
||||
...articles.map((article) => ({
|
||||
path: `/presse/blog/${article.slug}`,
|
||||
slug: `presse-blog-${article.slug}`,
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
source: 'blog' as const,
|
||||
})),
|
||||
]
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import { useParams, Link } from '@/spa/router';
|
||||
import { ArrowLeft, Clock, User, Tag, ArrowRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getArticleBySlug, articles } from '@/spa/data/blog';
|
||||
import { getArticleBySlug, articles, type BlogArticle } from '@/spa/data/blog';
|
||||
import { useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { docImageUrl } from '@/spa/cmsMediaField';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
|
||||
const NAVY = '#111D55';
|
||||
@@ -21,7 +23,22 @@ const CAT_COLORS: Record<string, string> = {
|
||||
export default function BlogDetail() {
|
||||
const isMobile = useIsMobile();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const article = slug ? getArticleBySlug(slug) : undefined;
|
||||
const route = useCmsRoute();
|
||||
const cmsArticle = route?.collection === 'posts' ? route.doc : undefined;
|
||||
const article = (slug ? getArticleBySlug(slug) : undefined) || (cmsArticle ? {
|
||||
slug: String(cmsArticle.slug || slug),
|
||||
title: String(cmsArticle.title || 'Presse'),
|
||||
cat: String(cmsArticle.cat || 'Presse'),
|
||||
date: String(cmsArticle.date || 'Aktuell'),
|
||||
author: String(cmsArticle.authorName || 'BMP Redaktion'),
|
||||
authorRole: String(cmsArticle.authorRole || 'Presse'),
|
||||
readTime: String(cmsArticle.readTime || '1 min'),
|
||||
img: docImageUrl(cmsArticle, '/images/gala-saal-overview.jpg', 'heroImage'),
|
||||
excerpt: String(cmsArticle.excerpt || cmsArticle.meta?.description || 'Dieser Pressebeitrag wird aus Payload CMS geladen.'),
|
||||
sections: Array.isArray(cmsArticle.sections) && cmsArticle.sections.length > 0 ? cmsArticle.sections as any : [{ body: String(cmsArticle.excerpt || cmsArticle.meta?.description || 'Dieser Pressebeitrag wird aus Payload CMS geladen.') }],
|
||||
tags: Array.isArray(cmsArticle.tags) ? cmsArticle.tags.map((item: any) => item.tag).filter(Boolean) : ['Presse'],
|
||||
relatedSlugs: Array.isArray(cmsArticle.relatedSlugs) ? cmsArticle.relatedSlugs.map((item: any) => item.slug).filter(Boolean) : [],
|
||||
} as BlogArticle : undefined);
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
|
||||
40
src/spa/pages/CmsGenericPage.tsx
Normal file
40
src/spa/pages/CmsGenericPage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useCmsRoute } from '@/spa/cmsRoute'
|
||||
|
||||
const NAVY = '#111D55'
|
||||
const GOLD = '#EFBF04'
|
||||
const CREAM = '#EFE5E3'
|
||||
const FF = '"IBM Plex Sans", sans-serif'
|
||||
const FB = '"Inter", sans-serif'
|
||||
|
||||
export default function CmsGenericPage() {
|
||||
const route = useCmsRoute()
|
||||
const doc = route?.doc
|
||||
const title = doc?.title || doc?.meta?.title || 'Seite'
|
||||
const description = doc?.description || doc?.meta?.description
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '70vh', background: CREAM, padding: '140px 24px 80px' }}>
|
||||
<div style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<div style={{ color: GOLD, fontFamily: FF, fontWeight: 800, fontSize: 12, letterSpacing: '0.18em', textTransform: 'uppercase', marginBottom: 20 }}>
|
||||
{route?.collection || 'CMS'}
|
||||
</div>
|
||||
<h1 style={{ color: NAVY, fontFamily: FF, fontSize: 'clamp(2.25rem, 7vw, 5rem)', lineHeight: 1, fontWeight: 900, textTransform: 'uppercase', margin: 0 }}>
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p style={{ color: 'rgba(58,58,58,0.78)', fontFamily: FB, fontSize: 22, lineHeight: 1.6, marginTop: 28, maxWidth: 760 }}>
|
||||
{description}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: 'rgba(58,58,58,0.65)', fontFamily: FB, fontSize: 18, lineHeight: 1.7, marginTop: 28, maxWidth: 760 }}>
|
||||
Diese Route wird aus Payload CMS geladen. Ergänze Inhalte im entsprechenden Collection-Dokument.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import React, { useState } from 'react'
|
||||
import { useParams, Link } from '@/spa/router'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Calendar, MapPin, Clock, Bell, BellOff, ArrowLeft, ChevronRight, Users, Tag } from 'lucide-react'
|
||||
import { getEventBySlug, EventUpdate, UpdateType } from '@/spa/data/events'
|
||||
import { getEventBySlug, EventUpdate, UpdateType, type BmpEvent } from '@/spa/data/events'
|
||||
import { useCmsRoute } from '@/spa/cmsRoute'
|
||||
import { docImageUrl } from '@/spa/cmsMediaField'
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile'
|
||||
|
||||
const NAVY = '#111D55'
|
||||
@@ -75,7 +77,24 @@ function UpdateCard({ update, index }: { update: EventUpdate; index: number }) {
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
export default function EventDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const event = getEventBySlug(slug ?? '')
|
||||
const route = useCmsRoute()
|
||||
const cmsEvent = route?.collection === 'events' ? route.doc : undefined
|
||||
const event = getEventBySlug(slug ?? '') || (cmsEvent ? {
|
||||
slug: String(cmsEvent.slug || slug),
|
||||
title: String(cmsEvent.title || 'Event'),
|
||||
subtitle: String(cmsEvent.subtitle || cmsEvent.description || cmsEvent.meta?.description || ''),
|
||||
date: String(cmsEvent.date || 'Termin folgt'),
|
||||
time: String(cmsEvent.time || 'Uhrzeit folgt'),
|
||||
location: String(cmsEvent.location || 'Bayern'),
|
||||
venue: String(cmsEvent.venue || 'Ort folgt'),
|
||||
category: String(cmsEvent.category || 'Event'),
|
||||
status: (cmsEvent.status as any) || 'geplant',
|
||||
img: docImageUrl(cmsEvent, '/images/buehne-moderatoren.jpg'),
|
||||
description: String(cmsEvent.description || cmsEvent.meta?.description || ''),
|
||||
longDescription: String(cmsEvent.longDescription || cmsEvent.description || cmsEvent.meta?.description || 'Dieses Event wird aus Payload CMS geladen.'),
|
||||
eckdaten: Array.isArray(cmsEvent.eckdaten) ? cmsEvent.eckdaten as any : [],
|
||||
updates: Array.isArray(cmsEvent.updates) ? cmsEvent.updates as any : [],
|
||||
} as BmpEvent : undefined)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useMemo } from 'react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
|
||||
import { WINNERS, CATEGORIES, YEARS } from '@/spa/data/winners';
|
||||
import { useCmsCollection } from '@/spa/cmsRoute';
|
||||
import { docImageUrl } from '@/spa/cmsMediaField';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
@@ -17,14 +19,41 @@ export default function Preistraeger() {
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [mediaOnly, setMediaOnly] = useState(false);
|
||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||
const winners = useMemo(() => {
|
||||
const existingSlugs = new Set(WINNERS.map((winner) => winner.slug));
|
||||
return [
|
||||
...WINNERS,
|
||||
...cmsPreistraeger
|
||||
.filter((doc) => doc.slug && !existingSlugs.has(String(doc.slug)))
|
||||
.map((doc) => ({
|
||||
id: String(doc.id),
|
||||
slug: String(doc.slug),
|
||||
name: String(doc.title || 'Preisträger'),
|
||||
category: String(doc.category || 'Preisträger'),
|
||||
year: Number(doc.year || new Date().getFullYear()),
|
||||
type: String(doc.awardType || 'Auszeichnung'),
|
||||
img: docImageUrl(doc, '/images/gala-saal-overview.jpg'),
|
||||
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
|
||||
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
|
||||
quote: String(doc.quote || ''),
|
||||
quotePerson: String(doc.quotePerson || ''),
|
||||
quoteRole: String(doc.quoteRole || ''),
|
||||
location: String(doc.location || 'Bayern'),
|
||||
industry: String(doc.industry || 'Mittelstand'),
|
||||
website: String(doc.website || '#'),
|
||||
hasMedia: Boolean(doc.hasMedia),
|
||||
})),
|
||||
];
|
||||
}, [cmsPreistraeger]);
|
||||
|
||||
const filtered = useMemo(() => WINNERS.filter(w => {
|
||||
const filtered = useMemo(() => winners.filter(w => {
|
||||
if (w.year !== activeYear) return false;
|
||||
if (filterCategory && w.category !== filterCategory) return false;
|
||||
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (mediaOnly && !w.hasMedia) return false;
|
||||
return true;
|
||||
}), [activeYear, filterCategory, search, mediaOnly]);
|
||||
}), [activeYear, filterCategory, search, mediaOnly, winners]);
|
||||
|
||||
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
|
||||
const hasFilter = !!(filterCategory || search || mediaOnly);
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { Link, useParams, Navigate } from '@/spa/router';
|
||||
import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight } from 'lucide-react';
|
||||
import { WINNERS } from '@/spa/data/winners';
|
||||
import { useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { docImageUrl } from '@/spa/cmsMediaField';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
@@ -14,7 +16,26 @@ const BG_ALT = '#E4E2E3';
|
||||
|
||||
export default function PreistraegerDetail() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const winner = WINNERS.find(w => w.slug === slug);
|
||||
const route = useCmsRoute();
|
||||
const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined;
|
||||
const winner = WINNERS.find(w => w.slug === slug) || (cmsWinner ? {
|
||||
id: String(cmsWinner.id),
|
||||
slug: String(cmsWinner.slug || slug),
|
||||
name: String(cmsWinner.title || 'Preisträger'),
|
||||
category: String(cmsWinner.category || 'Preisträger'),
|
||||
year: Number(cmsWinner.year || new Date().getFullYear()),
|
||||
type: String(cmsWinner.awardType || 'Auszeichnung'),
|
||||
img: docImageUrl(cmsWinner, '/images/gala-saal-overview.jpg'),
|
||||
shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''),
|
||||
longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || 'Dieser Preisträger wird aus Payload CMS geladen.'),
|
||||
quote: String(cmsWinner.quote || 'Diese Erfolgsgeschichte wird aktuell in Payload CMS gepflegt.'),
|
||||
quotePerson: String(cmsWinner.quotePerson || cmsWinner.title || 'BMP'),
|
||||
quoteRole: String(cmsWinner.quoteRole || 'Preisträger'),
|
||||
location: String(cmsWinner.location || 'Bayern'),
|
||||
industry: String(cmsWinner.industry || 'Mittelstand'),
|
||||
website: String(cmsWinner.website || '#'),
|
||||
hasMedia: Boolean(cmsWinner.hasMedia),
|
||||
} : undefined);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!winner) return <Navigate to="/preistraeger" replace />;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Link } from '@/spa/router';
|
||||
import { Calendar, MapPin, Download, ArrowRight, FileText } from 'lucide-react';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { useCmsCollection } from '@/spa/cmsRoute';
|
||||
import { docImageUrl } from '@/spa/cmsMediaField';
|
||||
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
@@ -183,6 +185,41 @@ function NewsCard({ item, idx, isMobile }: { item: typeof news[0]; idx: number;
|
||||
|
||||
const Press: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const cmsEvents = useCmsCollection('events');
|
||||
const cmsPosts = useCmsCollection('posts');
|
||||
const combinedEvents = React.useMemo(() => {
|
||||
const existing = new Set(events.map((event) => event.slug.split('/').pop()));
|
||||
return [
|
||||
...events,
|
||||
...cmsEvents
|
||||
.filter((event) => event.slug && !existing.has(String(event.slug)))
|
||||
.map((event) => ({
|
||||
title: String(event.title || 'Event'),
|
||||
date: String(event.date || 'Termin folgt'),
|
||||
location: String(event.location || event.venue || 'Bayern'),
|
||||
cat: String(event.category || 'Event'),
|
||||
status: String(event.status || 'Geplant'),
|
||||
img: docImageUrl(event, '/images/buehne-moderatoren.jpg'),
|
||||
desc: String(event.description || event.meta?.description || ''),
|
||||
slug: String(event.spaPath || `/presse/events/${event.slug}`),
|
||||
})),
|
||||
];
|
||||
}, [cmsEvents]);
|
||||
const combinedNews = React.useMemo(() => {
|
||||
const existing = new Set(news.map((item) => item.slug.split('/').pop()));
|
||||
return [
|
||||
...news,
|
||||
...cmsPosts
|
||||
.filter((post) => post.slug && !existing.has(String(post.slug)))
|
||||
.map((post) => ({
|
||||
title: String(post.title || 'Presse'),
|
||||
excerpt: String(post.excerpt || post.meta?.description || ''),
|
||||
cat: String(post.cat || 'Presse'),
|
||||
img: docImageUrl(post, '/images/gala-saal-overview.jpg', 'heroImage'),
|
||||
slug: String(post.spaPath || `/presse/blog/${post.slug}`),
|
||||
})),
|
||||
];
|
||||
}, [cmsPosts]);
|
||||
const [hoveredEvent, setHoveredEvent] = useState<number | null>(null);
|
||||
const [dlHovered, setDlHovered] = useState(false);
|
||||
const [submitHovered, setSubmitHovered] = useState(false);
|
||||
@@ -375,7 +412,7 @@ const Press: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Event rows */}
|
||||
{events.map((event, idx) => (
|
||||
{combinedEvents.map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onMouseEnter={() => setHoveredEvent(idx)}
|
||||
@@ -477,7 +514,7 @@ const Press: React.FC = () => {
|
||||
|
||||
{/* 3-column news grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
|
||||
{news.map((item, idx) => (
|
||||
{combinedNews.map((item, idx) => (
|
||||
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PayloadRequest, CollectionSlug } from 'payload'
|
||||
const collectionPrefixMap: Partial<Record<CollectionSlug, string>> = {
|
||||
posts: '/posts',
|
||||
pages: '',
|
||||
events: '',
|
||||
preistraeger: '',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -17,11 +19,11 @@ export const generatePreviewPath = ({ collection, slug }: Props) => {
|
||||
return null
|
||||
}
|
||||
|
||||
// Encode to support slugs with special characters
|
||||
const encodedSlug = encodeURIComponent(slug)
|
||||
const prefix = collectionPrefixMap[collection] || ''
|
||||
const path = slug.startsWith('/') ? slug : `${prefix}/${slug}`
|
||||
|
||||
const encodedParams = new URLSearchParams({
|
||||
path: `${collectionPrefixMap[collection]}/${encodedSlug}`,
|
||||
path: path.replace(/\/+/g, '/') || '/',
|
||||
previewSecret: process.env.PREVIEW_SECRET || '',
|
||||
} satisfies PreviewSearchParams)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user