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

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