feat(cms): add network page video upload

This commit is contained in:
syntaxbullet
2026-07-31 15:08:34 +02:00
parent ab47571139
commit e3c47c4500
11 changed files with 366 additions and 22 deletions

View File

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

View File

@@ -100,6 +100,24 @@ export const netzwerkFields: Field[] = [
text('greetingTitleLine2', 'Greeting title line 2', netzwerkContent.patronage.greetingTitleLine2),
textarea('greetingQuote', 'Greeting quote', netzwerkContent.patronage.greetingQuote),
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),
],
},
{

View 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;'))
}
}

View File

@@ -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_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';
export const migrations = [
{
@@ -114,4 +115,9 @@ export const migrations = [
down: migration_20260731_160000_focused_payload_cleanup.down,
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',
},
];

View File

@@ -1263,6 +1263,14 @@ export interface Page {
greetingTitleLine2?: string | null;
greetingQuote?: 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.
@@ -3438,6 +3446,11 @@ export interface PagesSelect<T extends boolean = true> {
greetingTitleLine2?: T;
greetingQuote?: T;
greetingAttribution?: T;
greetingVideo?: T;
greetingVideoButtonLabel?: T;
greetingVideoTitle?: T;
greetingVideoDescription?: T;
greetingVideoCloseLabel?: T;
};
juryIntro?:
| T

View File

@@ -136,6 +136,7 @@ async function main() {
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
const [patronPeople, juryMembers] = await Promise.all([
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
juryMembersForPage(payload),
@@ -155,6 +156,7 @@ async function main() {
},
patronage: {
...netzwerkContent.patronage,
greetingVideo: existingGreetingVideo,
people: patronPeople,
},
jury: {

View File

@@ -38,6 +38,10 @@ export const netzwerkContent = {
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.“',
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: {
eyebrow: 'Wer entscheidet?',

View File

@@ -6,7 +6,6 @@ import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
@@ -19,22 +18,11 @@ const GOLD = '#EFBF04';
type NetzwerkCms = Omit<Partial<typeof netzwerkContent>, 'hero' | 'jury' | 'sponsoring'> & {
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
patronage?: Partial<typeof netzwerkContent.patronage> & { greetingVideo?: unknown }
jury?: Partial<Omit<typeof netzwerkContent.jury, 'members'>> & { members?: unknown[] | null }
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 JuryMember = (typeof netzwerkContent.jury.members)[number] & {
active?: boolean | null
@@ -468,11 +456,8 @@ const Netzwerk: React.FC = () => {
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
const greetingVideoRef = useRef<HTMLVideoElement>(null);
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 cmsJuryMembers = useCmsCollection('jury-mitglieder');
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
@@ -499,10 +484,11 @@ const Netzwerk: React.FC = () => {
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
const greetingVideoSrc = mediaUrl(patronage.greetingVideo, '');
const greetingVideoTitle = fallbackText(patronage.greetingVideoTitle, netzwerkContent.patronage.greetingVideoTitle);
const greetingVideoDescription = fallbackText(patronage.greetingVideoDescription, netzwerkContent.patronage.greetingVideoDescription);
const greetingVideoLabel = fallbackText(patronage.greetingVideoButtonLabel, netzwerkContent.patronage.greetingVideoButtonLabel);
const greetingVideoCloseLabel = fallbackText(patronage.greetingVideoCloseLabel, netzwerkContent.patronage.greetingVideoCloseLabel);
useEffect(() => {
if (!showGreetingVideo || !greetingVideoSrc) return;
@@ -850,6 +836,10 @@ const Netzwerk: React.FC = () => {
{showGreetingVideo && greetingVideoSrc && (
<div
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={{
position: 'fixed',
inset: 0,
@@ -861,6 +851,8 @@ const Netzwerk: React.FC = () => {
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
type="button"
onClick={() => setShowGreetingVideo(false)}

View 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)
})
})

View File

@@ -99,6 +99,11 @@ describe('Payload field inventory', () => {
'pressIndex.events.statusLabels',
'pressIndex.downloads.contactName',
'netzwerk.jury.members',
'netzwerk.patronage.greetingVideo',
'netzwerk.patronage.greetingVideoButtonLabel',
'netzwerk.patronage.greetingVideoTitle',
'netzwerk.patronage.greetingVideoDescription',
'netzwerk.patronage.greetingVideoCloseLabel',
'netzwerk.partners.tiers',
'netzwerk.partners.modal.websiteLabel',
'netzwerk.sponsoringForm.steps',
@@ -130,4 +135,19 @@ describe('Payload field inventory', () => {
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')
})
})

View File

@@ -7,6 +7,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['tests/int/**/*.int.spec.ts'],
include: ['tests/int/**/*.int.spec.{ts,tsx}'],
},
})