feat(cms): add press contact image
This commit is contained in:
@@ -29,7 +29,7 @@ This inventory records the frontend or platform owner for every retained `Pages`
|
|||||||
| `formUpload.*` | `src/spa/pages/FormularUpload.tsx`. |
|
| `formUpload.*` | `src/spa/pages/FormularUpload.tsx`. |
|
||||||
| `mitgliedWerden.*` | `src/spa/pages/MitgliedWerden.tsx` and `src/spa/components/membership/MembershipWizard.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. |
|
| `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.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.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. |
|
| `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. |
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import type { Field } from 'payload'
|
|||||||
|
|
||||||
import { pressIndexContent } from '@/spa/pressIndexContent'
|
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,
|
name,
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
relationTo: 'media',
|
relationTo: 'media',
|
||||||
label,
|
label,
|
||||||
|
...(imageOnly
|
||||||
|
? {
|
||||||
|
filterOptions: {
|
||||||
|
mimeType: {
|
||||||
|
contains: 'image',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
admin: description ? { description } : undefined,
|
admin: description ? { description } : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -108,6 +117,13 @@ export const pressIndexFields: Field[] = [
|
|||||||
text('contactEyebrow', 'Contact eyebrow', pressIndexContent.downloads.contactEyebrow),
|
text('contactEyebrow', 'Contact eyebrow', pressIndexContent.downloads.contactEyebrow),
|
||||||
text('contactName', 'Contact name', pressIndexContent.downloads.contactName),
|
text('contactName', 'Contact name', pressIndexContent.downloads.contactName),
|
||||||
textarea('contactLines', 'Contact lines', pressIndexContent.downloads.contactLines),
|
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),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
181
src/migrations/20260731_180000_press_contact_image.ts
Normal file
181
src/migrations/20260731_180000_press_contact_image.ts
Normal 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;'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -120,4 +121,9 @@ export const migrations = [
|
|||||||
down: migration_20260731_170000_netzwerk_greeting_video.down,
|
down: migration_20260731_170000_netzwerk_greeting_video.down,
|
||||||
name: '20260731_170000_netzwerk_greeting_video',
|
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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1139,6 +1139,11 @@ export interface Page {
|
|||||||
contactEyebrow?: string | null;
|
contactEyebrow?: string | null;
|
||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
contactLines?: 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;
|
contactEyebrow?: T;
|
||||||
contactName?: T;
|
contactName?: T;
|
||||||
contactLines?: T;
|
contactLines?: T;
|
||||||
|
contactImage?: T;
|
||||||
|
contactImageAlt?: T;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
formUpload?:
|
formUpload?:
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ async function main() {
|
|||||||
const { imageFilename: _heroFilename, ...heroContent } = pressIndexContent.hero
|
const { imageFilename: _heroFilename, ...heroContent } = pressIndexContent.hero
|
||||||
const { collectionFallbackImageFilename: _eventFallbackFilename, ...eventsContent } = pressIndexContent.events
|
const { collectionFallbackImageFilename: _eventFallbackFilename, ...eventsContent } = pressIndexContent.events
|
||||||
const { collectionFallbackImageFilename: _newsFallbackFilename, ...newsContent } = pressIndexContent.news
|
const { collectionFallbackImageFilename: _newsFallbackFilename, ...newsContent } = pressIndexContent.news
|
||||||
|
const currentContactImage = page.pressIndex?.downloads?.contactImage
|
||||||
|
const currentContactImageID =
|
||||||
|
currentContactImage && typeof currentContactImage === 'object'
|
||||||
|
? currentContactImage.id
|
||||||
|
: currentContactImage
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: 'pages',
|
collection: 'pages',
|
||||||
id: page.id,
|
id: page.id,
|
||||||
@@ -51,7 +56,12 @@ async function main() {
|
|||||||
...newsContent,
|
...newsContent,
|
||||||
collectionFallbackImage: newsFallbackImage,
|
collectionFallbackImage: newsFallbackImage,
|
||||||
},
|
},
|
||||||
downloads: pressIndexContent.downloads,
|
downloads: {
|
||||||
|
...pressIndexContent.downloads,
|
||||||
|
contactImage: currentContactImageID ?? null,
|
||||||
|
contactImageAlt:
|
||||||
|
page.pressIndex?.downloads?.contactImageAlt ?? pressIndexContent.downloads.contactImageAlt,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type PressIndexCms = Partial<typeof pressIndexContent> & {
|
|||||||
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
|
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
|
||||||
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown }
|
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown }
|
||||||
news?: Partial<typeof pressIndexContent.news> & { 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;
|
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 newsSection = React.useMemo(() => ({ ...pressIndexContent.news, ...(cms.news || {}) }), [cms.news]);
|
||||||
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
|
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
|
||||||
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
|
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 cmsEvents = useCmsCollection('events');
|
||||||
const cmsPosts = useCmsCollection('posts');
|
const cmsPosts = useCmsCollection('posts');
|
||||||
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
|
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
|
||||||
@@ -385,13 +389,21 @@ const Press: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
|
{/* ── 4. PRESSE-MATERIAL & KONTAKT ────────────────────────────────── */}
|
||||||
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
|
||||||
<MunichSkylineBg />
|
<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
|
<div
|
||||||
|
data-testid="press-contact-copy"
|
||||||
style={{
|
style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
padding: isMobile ? '48px 24px' : '88px 80px',
|
padding: isMobile ? '48px 24px' : '88px 80px',
|
||||||
@@ -481,6 +493,25 @@ const Press: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,5 +39,6 @@ export const pressIndexContent = {
|
|||||||
contactEyebrow: 'Pressekontakt',
|
contactEyebrow: 'Pressekontakt',
|
||||||
contactName: 'Tanja Meier',
|
contactName: 'Tanja Meier',
|
||||||
contactLines: 'presse@bmp-bayern.de\n+49 89 123 456 99',
|
contactLines: 'presse@bmp-bayern.de\n+49 89 123 456 99',
|
||||||
|
contactImageAlt: '',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ describe('Payload field inventory', () => {
|
|||||||
'about.testimonials.items',
|
'about.testimonials.items',
|
||||||
'pressIndex.events.statusLabels',
|
'pressIndex.events.statusLabels',
|
||||||
'pressIndex.downloads.contactName',
|
'pressIndex.downloads.contactName',
|
||||||
|
'pressIndex.downloads.contactImage',
|
||||||
|
'pressIndex.downloads.contactImageAlt',
|
||||||
'netzwerk.jury.members',
|
'netzwerk.jury.members',
|
||||||
'netzwerk.patronage.greetingVideo',
|
'netzwerk.patronage.greetingVideo',
|
||||||
'netzwerk.patronage.greetingVideoButtonLabel',
|
'netzwerk.patronage.greetingVideoButtonLabel',
|
||||||
@@ -150,4 +152,19 @@ describe('Payload field inventory', () => {
|
|||||||
})
|
})
|
||||||
expect(field).not.toHaveProperty('defaultValue')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
123
tests/int/press-contact-image.int.spec.tsx
Normal file
123
tests/int/press-contact-image.int.spec.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user