From e3c47c4500150be0fb40caa6cb4ae2619f61789c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 15:08:34 +0200 Subject: [PATCH] feat(cms): add network page video upload --- docs/payload-field-inventory.md | 2 +- src/collections/Pages/netzwerkFields.ts | 18 ++ ...20260731_170000_netzwerk_greeting_video.ts | 169 ++++++++++++++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 13 ++ src/scripts/preload-netzwerk-page-cms.ts | 2 + src/spa/netzwerkContent.ts | 4 + src/spa/pages/Netzwerk.tsx | 32 ++-- tests/int/netzwerk-video.int.spec.tsx | 120 +++++++++++++ tests/int/payload-field-inventory.int.spec.ts | 20 +++ vitest.config.mts | 2 +- 11 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 src/migrations/20260731_170000_netzwerk_greeting_video.ts create mode 100644 tests/int/netzwerk-video.int.spec.tsx diff --git a/docs/payload-field-inventory.md b/docs/payload-field-inventory.md index b57cdb3..7a67b1e 100644 --- a/docs/payload-field-inventory.md +++ b/docs/payload-field-inventory.md @@ -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`. | diff --git a/src/collections/Pages/netzwerkFields.ts b/src/collections/Pages/netzwerkFields.ts index 85e12c9..efac8e8 100644 --- a/src/collections/Pages/netzwerkFields.ts +++ b/src/collections/Pages/netzwerkFields.ts @@ -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), ], }, { diff --git a/src/migrations/20260731_170000_netzwerk_greeting_video.ts b/src/migrations/20260731_170000_netzwerk_greeting_video.ts new file mode 100644 index 0000000..7b52c41 --- /dev/null +++ b/src/migrations/20260731_170000_netzwerk_greeting_video.ts @@ -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 { + 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 { + 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;')) + } +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 610d3e3..f081b17 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 2c547fd..b4d9b31 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 { greetingTitleLine2?: T; greetingQuote?: T; greetingAttribution?: T; + greetingVideo?: T; + greetingVideoButtonLabel?: T; + greetingVideoTitle?: T; + greetingVideoDescription?: T; + greetingVideoCloseLabel?: T; }; juryIntro?: | T diff --git a/src/scripts/preload-netzwerk-page-cms.ts b/src/scripts/preload-netzwerk-page-cms.ts index 6dbd493..5b5ba8c 100644 --- a/src/scripts/preload-netzwerk-page-cms.ts +++ b/src/scripts/preload-netzwerk-page-cms.ts @@ -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: { diff --git a/src/spa/netzwerkContent.ts b/src/spa/netzwerkContent.ts index a02d1aa..f46793d 100644 --- a/src/spa/netzwerkContent.ts +++ b/src/spa/netzwerkContent.ts @@ -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?', diff --git a/src/spa/pages/Netzwerk.tsx b/src/spa/pages/Netzwerk.tsx index 5188dd8..b429c03 100644 --- a/src/spa/pages/Netzwerk.tsx +++ b/src/spa/pages/Netzwerk.tsx @@ -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, 'hero' | 'jury' | 'sponsoring'> & { hero?: Partial & { image?: unknown } + patronage?: Partial & { greetingVideo?: unknown } jury?: Partial> & { members?: unknown[] | null } sponsoring?: Partial & { 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(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(juryIntro.stats, netzwerkContent.juryIntro.stats); const desktopStats = fallbackArray(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats); const sponsorBenefits = fallbackArray(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 && (
{ 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, }} > +

{greetingVideoTitle}

+

{greetingVideoDescription}