feat(cms): add network page video upload
This commit is contained in:
@@ -30,7 +30,7 @@ This inventory records the frontend or platform owner for every retained `Pages`
|
|||||||
| `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. |
|
||||||
| `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. |
|
| `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. |
|
||||||
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
|
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
|
||||||
|
|||||||
@@ -100,6 +100,24 @@ export const netzwerkFields: Field[] = [
|
|||||||
text('greetingTitleLine2', 'Greeting title line 2', netzwerkContent.patronage.greetingTitleLine2),
|
text('greetingTitleLine2', 'Greeting title line 2', netzwerkContent.patronage.greetingTitleLine2),
|
||||||
textarea('greetingQuote', 'Greeting quote', netzwerkContent.patronage.greetingQuote),
|
textarea('greetingQuote', 'Greeting quote', netzwerkContent.patronage.greetingQuote),
|
||||||
text('greetingAttribution', 'Greeting attribution', netzwerkContent.patronage.greetingAttribution),
|
text('greetingAttribution', 'Greeting attribution', netzwerkContent.patronage.greetingAttribution),
|
||||||
|
{
|
||||||
|
name: 'greetingVideo',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Greeting video',
|
||||||
|
filterOptions: {
|
||||||
|
mimeType: {
|
||||||
|
contains: 'video',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
description: 'Choose the Ilse Aigner greeting video. If empty, the video action is hidden.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text('greetingVideoButtonLabel', 'Video button label', netzwerkContent.patronage.greetingVideoButtonLabel),
|
||||||
|
text('greetingVideoTitle', 'Video title', netzwerkContent.patronage.greetingVideoTitle),
|
||||||
|
text('greetingVideoDescription', 'Video description', netzwerkContent.patronage.greetingVideoDescription),
|
||||||
|
text('greetingVideoCloseLabel', 'Video close button label', netzwerkContent.patronage.greetingVideoCloseLabel),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
169
src/migrations/20260731_170000_netzwerk_greeting_video.ts
Normal file
169
src/migrations/20260731_170000_netzwerk_greeting_video.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||||
|
|
||||||
|
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||||
|
|
||||||
|
type MigrationDB = MigrateUpArgs['db']
|
||||||
|
|
||||||
|
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
|
||||||
|
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
const liveVideoColumn = 'netzwerk_patronage_greeting_video_id'
|
||||||
|
const versionVideoColumn = `version_${liveVideoColumn}`
|
||||||
|
const liveVideoIndex = 'pages_netzwerk_patronage_netzwerk_patronage_greeting_vid_idx'
|
||||||
|
const versionVideoIndex = '_pages_v_version_netzwerk_patronage_version_netzwerk_pat_idx'
|
||||||
|
|
||||||
|
const textColumns = [
|
||||||
|
['netzwerk_patronage_greeting_video_button_label', netzwerkContent.patronage.greetingVideoButtonLabel],
|
||||||
|
['netzwerk_patronage_greeting_video_title', netzwerkContent.patronage.greetingVideoTitle],
|
||||||
|
['netzwerk_patronage_greeting_video_description', netzwerkContent.patronage.greetingVideoDescription],
|
||||||
|
['netzwerk_patronage_greeting_video_close_label', netzwerkContent.patronage.greetingVideoCloseLabel],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
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 = `__task5_${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',
|
||||||
|
liveVideoColumn,
|
||||||
|
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
|
||||||
|
)
|
||||||
|
await addColumn(
|
||||||
|
db,
|
||||||
|
'_pages_v',
|
||||||
|
versionVideoColumn,
|
||||||
|
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [columnName, defaultValue] of textColumns) {
|
||||||
|
await addColumn(db, 'pages', columnName, `text DEFAULT ${literal(defaultValue)}`)
|
||||||
|
await addColumn(db, '_pages_v', `version_${columnName}`, `text DEFAULT ${literal(defaultValue)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(liveVideoIndex)} ON ${ident('pages')} (${ident(liveVideoColumn)});`),
|
||||||
|
)
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`CREATE INDEX IF NOT EXISTS ${ident(versionVideoIndex)} ON ${ident('_pages_v')} (${ident(versionVideoColumn)});`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||||
|
const liveColumns = [liveVideoColumn, ...textColumns.map(([columnName]) => columnName)]
|
||||||
|
const versionColumns = [versionVideoColumn, ...textColumns.map(([columnName]) => `version_${columnName}`)]
|
||||||
|
|
||||||
|
await db.run(sql.raw('PRAGMA foreign_keys=OFF;'))
|
||||||
|
try {
|
||||||
|
await rebuildTableWithoutColumns(db, 'pages', liveColumns)
|
||||||
|
await rebuildTableWithoutColumns(db, '_pages_v', versionColumns)
|
||||||
|
} finally {
|
||||||
|
await db.run(sql.raw('PRAGMA foreign_keys=ON;'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import * as migration_20260713_213000_netzwerk_jury_selection from './20260713_2
|
|||||||
import * as migration_20260713_220000_preistraeger_detail_cleanup from './20260713_220000_preistraeger_detail_cleanup';
|
import * as migration_20260713_220000_preistraeger_detail_cleanup from './20260713_220000_preistraeger_detail_cleanup';
|
||||||
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';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -114,4 +115,9 @@ export const migrations = [
|
|||||||
down: migration_20260731_160000_focused_payload_cleanup.down,
|
down: migration_20260731_160000_focused_payload_cleanup.down,
|
||||||
name: '20260731_160000_focused_payload_cleanup',
|
name: '20260731_160000_focused_payload_cleanup',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260731_170000_netzwerk_greeting_video.up,
|
||||||
|
down: migration_20260731_170000_netzwerk_greeting_video.down,
|
||||||
|
name: '20260731_170000_netzwerk_greeting_video',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1263,6 +1263,14 @@ export interface Page {
|
|||||||
greetingTitleLine2?: string | null;
|
greetingTitleLine2?: string | null;
|
||||||
greetingQuote?: string | null;
|
greetingQuote?: string | null;
|
||||||
greetingAttribution?: string | null;
|
greetingAttribution?: string | null;
|
||||||
|
/**
|
||||||
|
* Choose the Ilse Aigner greeting video. If empty, the video action is hidden.
|
||||||
|
*/
|
||||||
|
greetingVideo?: (number | null) | Media;
|
||||||
|
greetingVideoButtonLabel?: string | null;
|
||||||
|
greetingVideoTitle?: string | null;
|
||||||
|
greetingVideoDescription?: string | null;
|
||||||
|
greetingVideoCloseLabel?: string | null;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Editorial jury intro and stat strips.
|
* Editorial jury intro and stat strips.
|
||||||
@@ -3438,6 +3446,11 @@ export interface PagesSelect<T extends boolean = true> {
|
|||||||
greetingTitleLine2?: T;
|
greetingTitleLine2?: T;
|
||||||
greetingQuote?: T;
|
greetingQuote?: T;
|
||||||
greetingAttribution?: T;
|
greetingAttribution?: T;
|
||||||
|
greetingVideo?: T;
|
||||||
|
greetingVideoButtonLabel?: T;
|
||||||
|
greetingVideoTitle?: T;
|
||||||
|
greetingVideoDescription?: T;
|
||||||
|
greetingVideoCloseLabel?: T;
|
||||||
};
|
};
|
||||||
juryIntro?:
|
juryIntro?:
|
||||||
| T
|
| T
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ async function main() {
|
|||||||
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
|
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
|
||||||
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
|
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
|
||||||
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
|
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
|
||||||
|
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
|
||||||
const [patronPeople, juryMembers] = await Promise.all([
|
const [patronPeople, juryMembers] = await Promise.all([
|
||||||
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
|
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
|
||||||
juryMembersForPage(payload),
|
juryMembersForPage(payload),
|
||||||
@@ -155,6 +156,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
patronage: {
|
patronage: {
|
||||||
...netzwerkContent.patronage,
|
...netzwerkContent.patronage,
|
||||||
|
greetingVideo: existingGreetingVideo,
|
||||||
people: patronPeople,
|
people: patronPeople,
|
||||||
},
|
},
|
||||||
jury: {
|
jury: {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const netzwerkContent = {
|
|||||||
greetingQuote:
|
greetingQuote:
|
||||||
'„Der Bayerische Mittelstandspreis steht für das, was Bayern stark macht: Unternehmergeist, Verantwortung und Qualität. Mit großer Freude übernehme ich erneut die Schirmherrschaft für diesen bedeutenden Preis.“',
|
'„Der Bayerische Mittelstandspreis steht für das, was Bayern stark macht: Unternehmergeist, Verantwortung und Qualität. Mit großer Freude übernehme ich erneut die Schirmherrschaft für diesen bedeutenden Preis.“',
|
||||||
greetingAttribution: '– Ilse Aigner MdL, Präsidentin des Bayerischen Landtags',
|
greetingAttribution: '– Ilse Aigner MdL, Präsidentin des Bayerischen Landtags',
|
||||||
|
greetingVideoButtonLabel: 'Film abspielen',
|
||||||
|
greetingVideoTitle: 'Grußwort von Ilse Aigner MdL',
|
||||||
|
greetingVideoDescription: 'Video-Grußwort der Schirmherrin des Bayerischen Mittelstandspreises.',
|
||||||
|
greetingVideoCloseLabel: 'Schließen',
|
||||||
},
|
},
|
||||||
juryIntro: {
|
juryIntro: {
|
||||||
eyebrow: 'Wer entscheidet?',
|
eyebrow: 'Wer entscheidet?',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
|||||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||||
import { homeContent } from '@/spa/homeContent';
|
|
||||||
import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
|
import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
|
||||||
import { netzwerkContent } from '@/spa/netzwerkContent';
|
import { netzwerkContent } from '@/spa/netzwerkContent';
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
@@ -19,22 +18,11 @@ const GOLD = '#EFBF04';
|
|||||||
|
|
||||||
type NetzwerkCms = Omit<Partial<typeof netzwerkContent>, 'hero' | 'jury' | 'sponsoring'> & {
|
type NetzwerkCms = Omit<Partial<typeof netzwerkContent>, 'hero' | 'jury' | 'sponsoring'> & {
|
||||||
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
|
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
|
||||||
|
patronage?: Partial<typeof netzwerkContent.patronage> & { greetingVideo?: unknown }
|
||||||
jury?: Partial<Omit<typeof netzwerkContent.jury, 'members'>> & { members?: unknown[] | null }
|
jury?: Partial<Omit<typeof netzwerkContent.jury, 'members'>> & { members?: unknown[] | null }
|
||||||
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
|
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
|
||||||
}
|
}
|
||||||
|
|
||||||
type HomeVideoCms = {
|
|
||||||
hero?: {
|
|
||||||
videoLabel?: string | null
|
|
||||||
}
|
|
||||||
videoModal?: {
|
|
||||||
video?: unknown
|
|
||||||
title?: string | null
|
|
||||||
description?: string | null
|
|
||||||
closeLabel?: string | null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
|
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
|
||||||
type JuryMember = (typeof netzwerkContent.jury.members)[number] & {
|
type JuryMember = (typeof netzwerkContent.jury.members)[number] & {
|
||||||
active?: boolean | null
|
active?: boolean | null
|
||||||
@@ -468,11 +456,8 @@ const Netzwerk: React.FC = () => {
|
|||||||
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
|
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
|
||||||
const greetingVideoRef = useRef<HTMLVideoElement>(null);
|
const greetingVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
|
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
|
||||||
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
|
|
||||||
const cmsPartners = useCmsCollection('partners') as unknown as Partner[];
|
const cmsPartners = useCmsCollection('partners') as unknown as Partner[];
|
||||||
const cmsJuryMembers = useCmsCollection('jury-mitglieder');
|
const cmsJuryMembers = useCmsCollection('jury-mitglieder');
|
||||||
const home = (homePage?.home || {}) as HomeVideoCms;
|
|
||||||
const homeVideoModal = home.videoModal || {};
|
|
||||||
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
|
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
|
||||||
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
|
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
|
||||||
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
|
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
|
||||||
@@ -499,10 +484,11 @@ const Netzwerk: React.FC = () => {
|
|||||||
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
||||||
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
|
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
|
||||||
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
|
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
|
||||||
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
|
const greetingVideoSrc = mediaUrl(patronage.greetingVideo, '');
|
||||||
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
|
const greetingVideoTitle = fallbackText(patronage.greetingVideoTitle, netzwerkContent.patronage.greetingVideoTitle);
|
||||||
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
|
const greetingVideoDescription = fallbackText(patronage.greetingVideoDescription, netzwerkContent.patronage.greetingVideoDescription);
|
||||||
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
|
const greetingVideoLabel = fallbackText(patronage.greetingVideoButtonLabel, netzwerkContent.patronage.greetingVideoButtonLabel);
|
||||||
|
const greetingVideoCloseLabel = fallbackText(patronage.greetingVideoCloseLabel, netzwerkContent.patronage.greetingVideoCloseLabel);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showGreetingVideo || !greetingVideoSrc) return;
|
if (!showGreetingVideo || !greetingVideoSrc) return;
|
||||||
@@ -850,6 +836,10 @@ const Netzwerk: React.FC = () => {
|
|||||||
{showGreetingVideo && greetingVideoSrc && (
|
{showGreetingVideo && greetingVideoSrc && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
|
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="netzwerk-greeting-video-title"
|
||||||
|
aria-describedby="netzwerk-greeting-video-description"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -861,6 +851,8 @@ const Netzwerk: React.FC = () => {
|
|||||||
padding: isMobile ? 16 : 24,
|
padding: isMobile ? 16 : 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<h2 id="netzwerk-greeting-video-title" className="sr-only">{greetingVideoTitle}</h2>
|
||||||
|
<p id="netzwerk-greeting-video-description" className="sr-only">{greetingVideoDescription}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowGreetingVideo(false)}
|
onClick={() => setShowGreetingVideo(false)}
|
||||||
|
|||||||
120
tests/int/netzwerk-video.int.spec.tsx
Normal file
120
tests/int/netzwerk-video.int.spec.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { CmsRouteProvider, type CmsRouteData, type CmsRouteDoc } from '@/spa/cmsRoute'
|
||||||
|
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||||
|
import Netzwerk from '@/spa/pages/Netzwerk'
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
usePathname: () => '/netzwerk',
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const homePage = (videoUrl: string): CmsRouteDoc => ({
|
||||||
|
id: `home-${videoUrl}`,
|
||||||
|
slug: 'startseite',
|
||||||
|
spaPath: '/',
|
||||||
|
home: {
|
||||||
|
hero: { videoLabel: 'Homepage film' },
|
||||||
|
videoModal: {
|
||||||
|
video: { url: videoUrl },
|
||||||
|
title: 'Homepage video',
|
||||||
|
closeLabel: 'Homepage video close',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const networkRoute = (video?: { url: string; label: string; title: string }): CmsRouteData => ({
|
||||||
|
collection: 'pages',
|
||||||
|
doc: {
|
||||||
|
id: 'network-page',
|
||||||
|
slug: 'netzwerk',
|
||||||
|
spaPath: '/netzwerk',
|
||||||
|
netzwerk: {
|
||||||
|
patronage: {
|
||||||
|
...(video
|
||||||
|
? {
|
||||||
|
greetingVideo: { url: video.url },
|
||||||
|
greetingVideoButtonLabel: video.label,
|
||||||
|
greetingVideoTitle: video.title,
|
||||||
|
greetingVideoDescription: `${video.title} description`,
|
||||||
|
greetingVideoCloseLabel: `${video.title} close`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
pages: [homePage('/api/media/file/homepage.mp4')],
|
||||||
|
partners: [],
|
||||||
|
'jury-mitglieder': [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Netzwerk greeting video', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
|
it('does not fall back to the homepage video when Netzwerk media is empty', () => {
|
||||||
|
render(
|
||||||
|
<CmsRouteProvider value={networkRoute()}>
|
||||||
|
<Netzwerk />
|
||||||
|
</CmsRouteProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: netzwerkContent.patronage.greetingVideoButtonLabel })).toBeNull()
|
||||||
|
expect(screen.queryByLabelText('Homepage video')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves the page-owned Netzwerk media independently of homepage changes', () => {
|
||||||
|
const firstNetworkVideo = {
|
||||||
|
url: '/api/media/file/network-first.mp4',
|
||||||
|
label: 'Play Netzwerk greeting',
|
||||||
|
title: 'Netzwerk greeting one',
|
||||||
|
}
|
||||||
|
const secondNetworkVideo = {
|
||||||
|
url: '/api/media/file/network-second.mp4',
|
||||||
|
label: 'Play second Netzwerk greeting',
|
||||||
|
title: 'Netzwerk greeting two',
|
||||||
|
}
|
||||||
|
const { rerender } = render(
|
||||||
|
<CmsRouteProvider value={networkRoute(firstNetworkVideo)}>
|
||||||
|
<Netzwerk />
|
||||||
|
</CmsRouteProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: firstNetworkVideo.label }))
|
||||||
|
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(firstNetworkVideo.url)
|
||||||
|
|
||||||
|
const homeChanged = networkRoute(firstNetworkVideo)
|
||||||
|
homeChanged.lists = {
|
||||||
|
...homeChanged.lists,
|
||||||
|
pages: [homePage('/api/media/file/homepage-changed.mp4')],
|
||||||
|
}
|
||||||
|
rerender(
|
||||||
|
<CmsRouteProvider value={homeChanged}>
|
||||||
|
<Netzwerk />
|
||||||
|
</CmsRouteProvider>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(firstNetworkVideo.url)
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<CmsRouteProvider value={networkRoute(secondNetworkVideo)}>
|
||||||
|
<Netzwerk />
|
||||||
|
</CmsRouteProvider>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(secondNetworkVideo.url)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -99,6 +99,11 @@ describe('Payload field inventory', () => {
|
|||||||
'pressIndex.events.statusLabels',
|
'pressIndex.events.statusLabels',
|
||||||
'pressIndex.downloads.contactName',
|
'pressIndex.downloads.contactName',
|
||||||
'netzwerk.jury.members',
|
'netzwerk.jury.members',
|
||||||
|
'netzwerk.patronage.greetingVideo',
|
||||||
|
'netzwerk.patronage.greetingVideoButtonLabel',
|
||||||
|
'netzwerk.patronage.greetingVideoTitle',
|
||||||
|
'netzwerk.patronage.greetingVideoDescription',
|
||||||
|
'netzwerk.patronage.greetingVideoCloseLabel',
|
||||||
'netzwerk.partners.tiers',
|
'netzwerk.partners.tiers',
|
||||||
'netzwerk.partners.modal.websiteLabel',
|
'netzwerk.partners.modal.websiteLabel',
|
||||||
'netzwerk.sponsoringForm.steps',
|
'netzwerk.sponsoringForm.steps',
|
||||||
@@ -130,4 +135,19 @@ describe('Payload field inventory', () => {
|
|||||||
maxRows: 2,
|
maxRows: 2,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('limits the Netzwerk greeting upload to video media', () => {
|
||||||
|
const field = findField(netzwerkFields, 'netzwerk.patronage.greetingVideo')
|
||||||
|
|
||||||
|
expect(field).toMatchObject({
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
filterOptions: {
|
||||||
|
mimeType: {
|
||||||
|
contains: 'video',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(field).not.toHaveProperty('defaultValue')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
include: ['tests/int/**/*.int.spec.ts'],
|
include: ['tests/int/**/*.int.spec.{ts,tsx}'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user