feat(cms): add network page video upload
This commit is contained in:
@@ -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),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
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_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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user