diff --git a/package.json b/package.json index 553b4c1..4ac476f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/SpaMedia/config.ts b/src/SpaMedia/config.ts deleted file mode 100644 index 3878c35..0000000 --- a/src/SpaMedia/config.ts +++ /dev/null @@ -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, - }, - }, - ], -} diff --git a/src/app/(frontend)/[[...slug]]/page.tsx b/src/app/(frontend)/[[...slug]]/page.tsx index 638baba..2f2c3ab 100644 --- a/src/app/(frontend)/[[...slug]]/page.tsx +++ b/src/app/(frontend)/[[...slug]]/page.tsx @@ -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>, 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>, 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 ( ) } diff --git a/src/collections/Events.ts b/src/collections/Events.ts new file mode 100644 index 0000000..d3c477b --- /dev/null +++ b/src/collections/Events.ts @@ -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, + }, +} diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 0f380a7..9d0e00a 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -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: { diff --git a/src/collections/Posts/hooks/revalidatePost.ts b/src/collections/Posts/hooks/revalidatePost.ts index 1d0c0bb..cb4a52d 100644 --- a/src/collections/Posts/hooks/revalidatePost.ts +++ b/src/collections/Posts/hooks/revalidatePost.ts @@ -11,7 +11,7 @@ export const revalidatePost: CollectionAfterChangeHook = ({ }) => { 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 = ({ // 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 = ({ export const revalidateDelete: CollectionAfterDeleteHook = ({ doc, req: { context } }) => { if (!context.disableRevalidate) { - const path = `/posts/${doc?.slug}` + const path = doc?.spaPath || `/posts/${doc?.slug}` revalidatePath(path) revalidateTag('posts-sitemap', 'max') diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index 68ee399..5b9c65c 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -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', diff --git a/src/collections/Preistraeger.ts b/src/collections/Preistraeger.ts new file mode 100644 index 0000000..a50d385 --- /dev/null +++ b/src/collections/Preistraeger.ts @@ -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, + }, +} diff --git a/src/payload-types.ts b/src/payload-types.ts index d6ca460..08c8b71 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | PagesSelect; posts: PostsSelect | PostsSelect; + events: EventsSelect | EventsSelect; + preistraeger: PreistraegerSelect | PreistraegerSelect; media: MediaSelect | MediaSelect; categories: CategoriesSelect | CategoriesSelect; users: UsersSelect | UsersSelect; @@ -112,12 +116,10 @@ export interface Config { globals: { header: Header; footer: Footer; - 'spa-media': SpaMedia; }; globalsSelect: { header: HeaderSelect | HeaderSelect; footer: FooterSelect | FooterSelect; - 'spa-media': SpaMediaSelect | SpaMediaSelect; }; 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 { description?: T; }; publishedAt?: T; + spaPath?: T; generateSlug?: T; slug?: T; updatedAt?: T; @@ -1197,6 +1334,31 @@ export interface FormBlockSelect { export interface PostsSelect { 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 { image?: T; description?: T; }; + spaPath?: T; publishedAt?: T; authors?: T; populatedAuthors?: @@ -1221,6 +1384,86 @@ export interface PostsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "events_select". + */ +export interface EventsSelect { + 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 { + 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 { createdAt?: T; globalType?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "spa-media_select". - */ -export interface SpaMediaSelect { - 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; diff --git a/src/payload.config.ts b/src/payload.config.ts index 23e76f7..4cd93e0 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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, diff --git a/src/scripts/migrate-spa-media.ts b/src/scripts/migrate-spa-media.ts index 9b167e3..b019372 100644 --- a/src/scripts/migrate-spa-media.ts +++ b/src/scripts/migrate-spa-media.ts @@ -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') diff --git a/src/scripts/migrate-spa-pages.ts b/src/scripts/migrate-spa-pages.ts new file mode 100644 index 0000000..a3fe4ad --- /dev/null +++ b/src/scripts/migrate-spa-pages.ts @@ -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> +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 + +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 { + 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) +}) diff --git a/src/spa/NextApp.tsx b/src/spa/NextApp.tsx index 1245aa5..70a8dd5 100644 --- a/src/spa/NextApp.tsx +++ b/src/spa/NextApp.tsx @@ -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 = { +export const routes: Record = { '/': Home, '/teilnahme': Participation, '/preistraeger': Preistraeger, @@ -36,24 +36,33 @@ const routes: Record = { '/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 ( <> - - - - - + + + + + + ) } diff --git a/src/spa/cmsMedia.ts b/src/spa/cmsMedia.ts deleted file mode 100644 index 7e1c24a..0000000 --- a/src/spa/cmsMedia.ts +++ /dev/null @@ -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 - -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 - }, {}) -} diff --git a/src/spa/cmsMediaField.ts b/src/spa/cmsMediaField.ts new file mode 100644 index 0000000..61fabe7 --- /dev/null +++ b/src/spa/cmsMediaField.ts @@ -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) +} diff --git a/src/spa/cmsRoute.tsx b/src/spa/cmsRoute.tsx new file mode 100644 index 0000000..f1c3279 --- /dev/null +++ b/src/spa/cmsRoute.tsx @@ -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> +} + +const CmsRouteContext = createContext(undefined) + +export function CmsRouteProvider({ children, value }: { children: React.ReactNode; value?: CmsRouteData }) { + return {children} +} + +export function useCmsRoute() { + return useContext(CmsRouteContext) +} + +export function useCmsCollection(collection: CmsRouteCollection) { + return useCmsRoute()?.lists?.[collection] || [] +} diff --git a/src/spa/components/CmsMediaRewriter.tsx b/src/spa/components/CmsMediaRewriter.tsx deleted file mode 100644 index 4b22ee6..0000000 --- a/src/spa/components/CmsMediaRewriter.tsx +++ /dev/null @@ -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('[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 -} diff --git a/src/spa/pageRegistry.ts b/src/spa/pageRegistry.ts new file mode 100644 index 0000000..3d213d2 --- /dev/null +++ b/src/spa/pageRegistry.ts @@ -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, + })), +] diff --git a/src/spa/pages/BlogDetail.tsx b/src/spa/pages/BlogDetail.tsx index b977a50..dc496b8 100644 --- a/src/spa/pages/BlogDetail.tsx +++ b/src/spa/pages/BlogDetail.tsx @@ -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 = { 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 ( diff --git a/src/spa/pages/CmsGenericPage.tsx b/src/spa/pages/CmsGenericPage.tsx new file mode 100644 index 0000000..8de7664 --- /dev/null +++ b/src/spa/pages/CmsGenericPage.tsx @@ -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 ( +
+
+
+ {route?.collection || 'CMS'} +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : ( +

+ Diese Route wird aus Payload CMS geladen. Ergänze Inhalte im entsprechenden Collection-Dokument. +

+ )} +
+
+ ) +} diff --git a/src/spa/pages/EventDetail.tsx b/src/spa/pages/EventDetail.tsx index f891558..b7793ca 100644 --- a/src/spa/pages/EventDetail.tsx +++ b/src/spa/pages/EventDetail.tsx @@ -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() diff --git a/src/spa/pages/Preistraeger.tsx b/src/spa/pages/Preistraeger.tsx index 04a03b8..85cac77 100644 --- a/src/spa/pages/Preistraeger.tsx +++ b/src/spa/pages/Preistraeger.tsx @@ -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); diff --git a/src/spa/pages/PreistraegerDetail.tsx b/src/spa/pages/PreistraegerDetail.tsx index 28c59c8..d576981 100644 --- a/src/spa/pages/PreistraegerDetail.tsx +++ b/src/spa/pages/PreistraegerDetail.tsx @@ -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 ; diff --git a/src/spa/pages/Press.tsx b/src/spa/pages/Press.tsx index 5041744..df80e0a 100644 --- a/src/spa/pages/Press.tsx +++ b/src/spa/pages/Press.tsx @@ -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(null); const [dlHovered, setDlHovered] = useState(false); const [submitHovered, setSubmitHovered] = useState(false); @@ -375,7 +412,7 @@ const Press: React.FC = () => { {/* Event rows */} - {events.map((event, idx) => ( + {combinedEvents.map((event, idx) => (
setHoveredEvent(idx)} @@ -477,7 +514,7 @@ const Press: React.FC = () => { {/* 3-column news grid */}
- {news.map((item, idx) => ( + {combinedNews.map((item, idx) => ( ))}
diff --git a/src/utilities/generatePreviewPath.ts b/src/utilities/generatePreviewPath.ts index eedc7d8..116cab2 100644 --- a/src/utilities/generatePreviewPath.ts +++ b/src/utilities/generatePreviewPath.ts @@ -4,6 +4,8 @@ import { PayloadRequest, CollectionSlug } from 'payload' const collectionPrefixMap: Partial> = { 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)