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

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