diff --git a/docs/payload-field-inventory.md b/docs/payload-field-inventory.md index 7a67b1e..92174b4 100644 --- a/docs/payload-field-inventory.md +++ b/docs/payload-field-inventory.md @@ -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. | diff --git a/src/collections/Pages/pressIndexFields.ts b/src/collections/Pages/pressIndexFields.ts index f52d149..cab788d 100644 --- a/src/collections/Pages/pressIndexFields.ts +++ b/src/collections/Pages/pressIndexFields.ts @@ -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), ], }, ], diff --git a/src/migrations/20260731_180000_press_contact_image.ts b/src/migrations/20260731_180000_press_contact_image.ts new file mode 100644 index 0000000..8cf6b0a --- /dev/null +++ b/src/migrations/20260731_180000_press_contact_image.ts @@ -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 { + 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 { + 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;')) + } +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index f081b17..9491ed4 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index b4d9b31..9293e09 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 { contactEyebrow?: T; contactName?: T; contactLines?: T; + contactImage?: T; + contactImageAlt?: T; }; }; formUpload?: diff --git a/src/scripts/preload-press-index-page-cms.ts b/src/scripts/preload-press-index-page-cms.ts index 89c8958..4b57f4b 100644 --- a/src/scripts/preload-press-index-page-cms.ts +++ b/src/scripts/preload-press-index-page-cms.ts @@ -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, }) diff --git a/src/spa/pages/Press.tsx b/src/spa/pages/Press.tsx index b516801..7b8855c 100644 --- a/src/spa/pages/Press.tsx +++ b/src/spa/pages/Press.tsx @@ -23,7 +23,7 @@ type PressIndexCms = Partial & { hero?: Partial & { image?: unknown } events?: Partial & { collectionFallbackImage?: unknown } news?: Partial & { collectionFallbackImage?: unknown } - downloads?: Partial + downloads?: Partial & { 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 = () => { - {/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */} + {/* ── 4. PRESSE-MATERIAL & KONTAKT ────────────────────────────────── */}
-
+
- {/* Left col */} + {/* Copy comes first in the DOM and visually, including in the mobile stack. */}
{
+ + {contactImageUrl && ( +
+ {contactImageAlt} +
+ )}
diff --git a/src/spa/pressIndexContent.ts b/src/spa/pressIndexContent.ts index b215ec8..d1de43b 100644 --- a/src/spa/pressIndexContent.ts +++ b/src/spa/pressIndexContent.ts @@ -39,5 +39,6 @@ export const pressIndexContent = { contactEyebrow: 'Pressekontakt', contactName: 'Tanja Meier', contactLines: 'presse@bmp-bayern.de\n+49 89 123 456 99', + contactImageAlt: '', }, } diff --git a/tests/int/payload-field-inventory.int.spec.ts b/tests/int/payload-field-inventory.int.spec.ts index 026855c..1d26a44 100644 --- a/tests/int/payload-field-inventory.int.spec.ts +++ b/tests/int/payload-field-inventory.int.spec.ts @@ -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') + }) }) diff --git a/tests/int/press-contact-image.int.spec.tsx b/tests/int/press-contact-image.int.spec.tsx new file mode 100644 index 0000000..03acee3 --- /dev/null +++ b/tests/int/press-contact-image.int.spec.tsx @@ -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( + + + , + ) + + 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( + + + , + ) + + expect(screen.getByTestId('press-contact-image')).toBeTruthy() + + rerender( + + + , + ) + + 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( + + + , + ) + + 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() + }) +})