chore: migrate pages and media to be cms controlled.

This commit is contained in:
syntaxbullet
2026-06-17 17:15:45 +02:00
parent 647967482b
commit 3e04a3c7e3
25 changed files with 1359 additions and 256 deletions

View File

@@ -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",

View File

@@ -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,
},
},
],
}

View File

@@ -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
View 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,
},
}

View File

@@ -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: {

View File

@@ -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')

View File

@@ -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',

View 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,
},
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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')

View 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)
})

View File

@@ -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>
</>
)
}

View File

@@ -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
View 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
View 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] || []
}

View File

@@ -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
View 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,
})),
]

View File

@@ -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 (

View 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>
)
}

View File

@@ -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()

View File

@@ -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);

View File

@@ -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 />;

View File

@@ -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>

View File

@@ -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)