feat(cms): add press contact image

This commit is contained in:
syntaxbullet
2026-07-31 15:21:21 +02:00
parent e3c47c4500
commit c2dc42f243
10 changed files with 399 additions and 7 deletions

View File

@@ -29,7 +29,7 @@ This inventory records the frontend or platform owner for every retained `Pages`
| `formUpload.*` | `src/spa/pages/FormularUpload.tsx`. |
| `mitgliedWerden.*` | `src/spa/pages/MitgliedWerden.tsx` and `src/spa/components/membership/MembershipWizard.tsx`. |
| `preistraegerIndex.*` | `src/spa/pages/Preistraeger.tsx`; winner records remain collection-owned. |
| `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. |
| `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. `pressIndex.downloads.contactImage` and `contactImageAlt` own the optional Presse contact visual; an empty relationship renders the same copy in a compact copy-only layout without a static image fallback. |
| `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. The page-owned `netzwerk.patronage.greetingVideo` Media relationship and its adjacent button/modal copy control only the Ilse Aigner greeting on `/netzwerk`; they never inherit `home.videoModal.*`. |
| `netzwerk.jury.{eyebrow,heading,description,chairBadge,members}` | Jury heading and authoritative rendered relationship selection in `src/spa/pages/Netzwerk.tsx`. |
| `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers,modal}` | Partner section, tier labels, and modal copy in `src/spa/pages/Netzwerk.tsx`; partner records remain collection-owned. |

View File

@@ -2,11 +2,20 @@ import type { Field } from 'payload'
import { pressIndexContent } from '@/spa/pressIndexContent'
const uploadField = (name: string, label: string, description?: string): Field => ({
const uploadField = (name: string, label: string, description?: string, imageOnly = false): Field => ({
name,
type: 'upload',
relationTo: 'media',
label,
...(imageOnly
? {
filterOptions: {
mimeType: {
contains: 'image',
},
},
}
: {}),
admin: description ? { description } : undefined,
})
@@ -108,6 +117,13 @@ export const pressIndexFields: Field[] = [
text('contactEyebrow', 'Contact eyebrow', pressIndexContent.downloads.contactEyebrow),
text('contactName', 'Contact name', pressIndexContent.downloads.contactName),
textarea('contactLines', 'Contact lines', pressIndexContent.downloads.contactLines),
uploadField(
'contactImage',
'Contact image',
'Optional image shown beside the press contact. Clear the selection to use the copy-only layout.',
true,
),
text('contactImageAlt', 'Contact image alt text', pressIndexContent.downloads.contactImageAlt),
],
},
],

View File

@@ -0,0 +1,181 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const liveImageColumn = 'press_index_downloads_contact_image_id'
const liveAltColumn = 'press_index_downloads_contact_image_alt'
const versionImageColumn = `version_${liveImageColumn}`
const versionAltColumn = `version_${liveAltColumn}`
const liveImageIndex = 'pages_press_index_downloads_press_index_downloads_contac_idx'
const versionImageIndex = '_pages_v_version_press_index_downloads_version_press_ind_idx'
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
if (!(await tableExists(db, tableName))) return false
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{
name: string
}>
return columns.some((column) => column.name === columnName)
}
async function columnNames(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return []
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{
name: string
}>
return columns.map((column) => column.name)
}
async function addColumn(
db: MigrationDB,
tableName: string,
columnName: string,
definition: string,
) {
if (await columnExists(db, tableName, columnName)) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
}
function splitDefinitions(value: string) {
const definitions: string[] = []
let current = ''
let depth = 0
let quote = ''
for (let index = 0; index < value.length; index += 1) {
const character = value[index]
const previous = value[index - 1]
if (quote) {
current += character
if (character === quote && previous !== '\\') quote = ''
continue
}
if (character === '`' || character === '"' || character === "'") {
quote = character
current += character
continue
}
if (character === '(') depth += 1
if (character === ')') depth -= 1
if (character === ',' && depth === 0) {
definitions.push(current.trim())
current = ''
continue
}
current += character
}
if (current.trim()) definitions.push(current.trim())
return definitions
}
function definitionColumnName(definition: string) {
const match = definition.trim().match(/^(?:`([^`]+)`|"([^"]+)"|\[([^\]]+)\]|([^\s]+))/)
return match?.[1] || match?.[2] || match?.[3] || match?.[4] || ''
}
function referencesColumn(definition: string, column: string) {
return [ident(column), `"${column}"`, `[${column}]`].some((value) => definition.includes(value))
}
async function rebuildTableWithoutColumns(
db: MigrationDB,
tableName: string,
removedColumns: readonly string[],
) {
const schemaRows = (await db.all(
sql.raw(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ sql: string }>
const schema = schemaRows[0]?.sql
if (!schema) return
const presentColumns = await columnNames(db, tableName)
const removed = removedColumns.filter((column) => presentColumns.includes(column))
if (!removed.length) return
const open = schema.indexOf('(')
const close = schema.lastIndexOf(')')
if (open < 0 || close < open) throw new Error(`Could not parse schema for ${tableName}`)
const keptDefinitions = splitDefinitions(schema.slice(open + 1, close)).filter((definition) => {
const columnName = definitionColumnName(definition)
const isConstraint = /^(CONSTRAINT|FOREIGN|PRIMARY|UNIQUE|CHECK)\b/i.test(columnName)
if (isConstraint) return !removed.some((column) => referencesColumn(definition, column))
return !removed.includes(columnName)
})
const keptColumns = presentColumns.filter((column) => !removed.includes(column))
const indexRows = (await db.all(
sql.raw(
`SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ${literal(tableName)} AND sql IS NOT NULL`,
),
)) as Array<{ sql: string }>
const keptIndexes = indexRows
.map((row) => row.sql)
.filter((indexSql) => !removed.some((column) => referencesColumn(indexSql, column)))
const temporaryTable = `__task6_${tableName}`
const columnList = keptColumns.map(ident).join(', ')
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(temporaryTable)};`))
await db.run(
sql.raw(
`CREATE TABLE ${ident(temporaryTable)} (${keptDefinitions.join(',\n')})${schema.slice(close + 1)};`,
),
)
await db.run(
sql.raw(
`INSERT INTO ${ident(temporaryTable)} (${columnList}) SELECT ${columnList} FROM ${ident(tableName)};`,
),
)
await db.run(sql.raw(`DROP TABLE ${ident(tableName)};`))
await db.run(sql.raw(`ALTER TABLE ${ident(temporaryTable)} RENAME TO ${ident(tableName)};`))
for (const indexSql of keptIndexes) await db.run(sql.raw(`${indexSql};`))
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await addColumn(
db,
'pages',
liveImageColumn,
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
)
await addColumn(db, 'pages', liveAltColumn, "text DEFAULT ''")
await addColumn(
db,
'_pages_v',
versionImageColumn,
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
)
await addColumn(db, '_pages_v', versionAltColumn, "text DEFAULT ''")
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(liveImageIndex)} ON ${ident('pages')} (${ident(liveImageColumn)});`,
),
)
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(versionImageIndex)} ON ${ident('_pages_v')} (${ident(versionImageColumn)});`,
),
)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(sql.raw('PRAGMA foreign_keys=OFF;'))
try {
await rebuildTableWithoutColumns(db, 'pages', [liveImageColumn, liveAltColumn])
await rebuildTableWithoutColumns(db, '_pages_v', [versionImageColumn, versionAltColumn])
} finally {
await db.run(sql.raw('PRAGMA foreign_keys=ON;'))
}
}

View File

@@ -18,6 +18,7 @@ import * as migration_20260713_220000_preistraeger_detail_cleanup from './202607
import * as migration_20260731_143000_remove_press_record_fallbacks from './20260731_143000_remove_press_record_fallbacks';
import * as migration_20260731_160000_focused_payload_cleanup from './20260731_160000_focused_payload_cleanup';
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
export const migrations = [
{
@@ -120,4 +121,9 @@ export const migrations = [
down: migration_20260731_170000_netzwerk_greeting_video.down,
name: '20260731_170000_netzwerk_greeting_video',
},
{
up: migration_20260731_180000_press_contact_image.up,
down: migration_20260731_180000_press_contact_image.down,
name: '20260731_180000_press_contact_image',
},
];

View File

@@ -1139,6 +1139,11 @@ export interface Page {
contactEyebrow?: string | null;
contactName?: string | null;
contactLines?: string | null;
/**
* Optional image shown beside the press contact. Clear the selection to use the copy-only layout.
*/
contactImage?: (number | null) | Media;
contactImageAlt?: string | null;
};
};
/**
@@ -3335,6 +3340,8 @@ export interface PagesSelect<T extends boolean = true> {
contactEyebrow?: T;
contactName?: T;
contactLines?: T;
contactImage?: T;
contactImageAlt?: T;
};
};
formUpload?:

View File

@@ -31,6 +31,11 @@ async function main() {
const { imageFilename: _heroFilename, ...heroContent } = pressIndexContent.hero
const { collectionFallbackImageFilename: _eventFallbackFilename, ...eventsContent } = pressIndexContent.events
const { collectionFallbackImageFilename: _newsFallbackFilename, ...newsContent } = pressIndexContent.news
const currentContactImage = page.pressIndex?.downloads?.contactImage
const currentContactImageID =
currentContactImage && typeof currentContactImage === 'object'
? currentContactImage.id
: currentContactImage
await payload.update({
collection: 'pages',
id: page.id,
@@ -51,7 +56,12 @@ async function main() {
...newsContent,
collectionFallbackImage: newsFallbackImage,
},
downloads: pressIndexContent.downloads,
downloads: {
...pressIndexContent.downloads,
contactImage: currentContactImageID ?? null,
contactImageAlt:
page.pressIndex?.downloads?.contactImageAlt ?? pressIndexContent.downloads.contactImageAlt,
},
},
} as never,
})

View File

@@ -23,7 +23,7 @@ type PressIndexCms = Partial<typeof pressIndexContent> & {
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown }
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown }
downloads?: Partial<typeof pressIndexContent.downloads>
downloads?: Partial<typeof pressIndexContent.downloads> & { contactImage?: unknown }
}
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
@@ -146,6 +146,10 @@ const Press: React.FC = () => {
const newsSection = React.useMemo(() => ({ ...pressIndexContent.news, ...(cms.news || {}) }), [cms.news]);
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
const contactImageUrl = mediaUrl(downloads.contactImage, '');
const contactImageAlt = typeof downloads.contactImageAlt === 'string'
? downloads.contactImageAlt
: mediaAlt(downloads.contactImage, '');
const cmsEvents = useCmsCollection('events');
const cmsPosts = useCmsCollection('posts');
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
@@ -385,13 +389,21 @@ const Press: React.FC = () => {
</div>
</section>
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
{/* ── 4. PRESSE-MATERIAL & KONTAKT ────────────────────────────────── */}
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
<div
data-testid="press-contact-layout"
data-layout={contactImageUrl ? (isMobile ? 'stacked' : 'two-column') : 'copy-only'}
style={{
display: 'grid',
gridTemplateColumns: isMobile || !contactImageUrl ? '1fr' : 'minmax(0, 1fr) minmax(320px, 1fr)',
}}
>
{/* Left col */}
{/* Copy comes first in the DOM and visually, including in the mobile stack. */}
<div
data-testid="press-contact-copy"
style={{
background: '#fff',
padding: isMobile ? '48px 24px' : '88px 80px',
@@ -481,6 +493,25 @@ const Press: React.FC = () => {
</div>
</div>
</div>
{contactImageUrl && (
<div
data-testid="press-contact-image"
style={{
minHeight: isMobile ? 320 : 560,
position: 'relative',
overflow: 'hidden',
background: NAVY,
}}
>
<Image
unoptimized
src={contactImageUrl}
alt={contactImageAlt}
style={{ width: '100%', height: '100%', position: 'absolute', inset: 0, objectFit: 'cover', display: 'block' }}
/>
</div>
)}
</div>
</section>
</div>

View File

@@ -39,5 +39,6 @@ export const pressIndexContent = {
contactEyebrow: 'Pressekontakt',
contactName: 'Tanja Meier',
contactLines: 'presse@bmp-bayern.de\n+49 89 123 456 99',
contactImageAlt: '',
},
}

View File

@@ -98,6 +98,8 @@ describe('Payload field inventory', () => {
'about.testimonials.items',
'pressIndex.events.statusLabels',
'pressIndex.downloads.contactName',
'pressIndex.downloads.contactImage',
'pressIndex.downloads.contactImageAlt',
'netzwerk.jury.members',
'netzwerk.patronage.greetingVideo',
'netzwerk.patronage.greetingVideoButtonLabel',
@@ -150,4 +152,19 @@ describe('Payload field inventory', () => {
})
expect(field).not.toHaveProperty('defaultValue')
})
it('limits the press contact upload to image media', () => {
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')
expect(field).toMatchObject({
type: 'upload',
relationTo: 'media',
filterOptions: {
mimeType: {
contains: 'image',
},
},
})
expect(field).not.toHaveProperty('defaultValue')
})
})

View File

@@ -0,0 +1,123 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import { pressIndexContent } from '@/spa/pressIndexContent'
import Press from '@/spa/pages/Press'
const viewport = vi.hoisted(() => ({ mobile: false }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => viewport.mobile,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/presse',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
const pressRoute = (contactImage?: unknown, contactImageAlt?: string): CmsRouteData => ({
collection: 'pages',
doc: {
id: 'press-page',
slug: 'presse',
spaPath: '/presse',
pressIndex: {
downloads: {
contactImage,
contactImageAlt,
},
},
},
lists: {
events: [],
posts: [],
},
})
describe('Presse contact image', () => {
beforeEach(() => {
viewport.mobile = false
})
afterEach(() => cleanup())
it('renders selected media and page-owned alt text in the desktop right column', () => {
render(
<CmsRouteProvider
value={pressRoute(
{ url: '/api/media/file/press-contact.jpg', alt: 'Media default alt' },
'Press team portrait',
)}
>
<Press />
</CmsRouteProvider>,
)
const layout = screen.getByTestId('press-contact-layout')
const imageColumn = screen.getByTestId('press-contact-image')
const image = within(imageColumn).getByRole('img', { name: 'Press team portrait' })
expect(layout.getAttribute('data-layout')).toBe('two-column')
expect(layout.style.gridTemplateColumns).toBe('minmax(0, 1fr) minmax(320px, 1fr)')
expect(image.getAttribute('src')).toBe('/api/media/file/press-contact.jpg')
expect(
screen.getByTestId('press-contact-copy').compareDocumentPosition(imageColumn) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('clears only the visual and keeps the contact copy in a compact fallback-free layout', () => {
const { rerender } = render(
<CmsRouteProvider
value={pressRoute({ url: '/api/media/file/press-contact.jpg' }, 'Press contact')}
>
<Press />
</CmsRouteProvider>,
)
expect(screen.getByTestId('press-contact-image')).toBeTruthy()
rerender(
<CmsRouteProvider value={pressRoute(undefined, '')}>
<Press />
</CmsRouteProvider>,
)
const layout = screen.getByTestId('press-contact-layout')
expect(screen.queryByTestId('press-contact-image')).toBeNull()
expect(layout.getAttribute('data-layout')).toBe('copy-only')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(
within(screen.getByTestId('press-contact-copy')).getByText(
pressIndexContent.downloads.contactName,
),
).toBeTruthy()
expect(within(layout).queryByRole('img')).toBeNull()
})
it('stacks copy before the selected image on mobile', () => {
viewport.mobile = true
render(
<CmsRouteProvider
value={pressRoute({ filename: 'press-contact-mobile.jpg' }, 'Mobile press contact')}
>
<Press />
</CmsRouteProvider>,
)
const layout = screen.getByTestId('press-contact-layout')
const copy = screen.getByTestId('press-contact-copy')
const imageColumn = screen.getByTestId('press-contact-image')
expect(layout.getAttribute('data-layout')).toBe('stacked')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(
within(imageColumn).getByRole('img', { name: 'Mobile press contact' }).getAttribute('src'),
).toBe('/api/media/file/press-contact-mobile.jpg')
expect(
copy.compareDocumentPosition(imageColumn) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
})