feat: add preistraeger placeholder image and related database migrations
- Introduced a new SVG placeholder image for preistraeger without an image. - Created migration scripts to add download zip and gallery features to the preistraeger table. - Added site settings for preistraeger placeholder image in the database. - Implemented a utility function to retrieve the preistraeger placeholder image URL in the SPA.
This commit is contained in:
10317
pnpm-lock.yaml
generated
Normal file
10317
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
public/images/preistraeger-placeholder.svg
Normal file
13
public/images/preistraeger-placeholder.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Neutraler Preistraeger Platzhalter</title>
|
||||||
|
<desc id="desc">Neutraler Platzhalter fuer Preistraeger ohne eigenes Bild.</desc>
|
||||||
|
<rect width="1200" height="900" fill="#E4E2E3"/>
|
||||||
|
<rect x="84" y="84" width="1032" height="732" fill="#F7F7F8"/>
|
||||||
|
<path d="M84 816 1116 84" stroke="#D0D5DD" stroke-width="2" opacity=".7"/>
|
||||||
|
<path d="M84 84 1116 816" stroke="#D0D5DD" stroke-width="2" opacity=".45"/>
|
||||||
|
<circle cx="600" cy="384" r="112" fill="#C6CBD3"/>
|
||||||
|
<path d="M360 720c22-146 124-224 240-224s218 78 240 224H360z" fill="#B8BEC8"/>
|
||||||
|
<rect x="84" y="84" width="1032" height="732" fill="none" stroke="#D0D5DD" stroke-width="3"/>
|
||||||
|
<rect x="84" y="812" width="1032" height="4" fill="#EFBF04"/>
|
||||||
|
<text x="600" y="782" text-anchor="middle" font-family="IBM Plex Sans, Arial, sans-serif" font-size="36" font-weight="700" letter-spacing="5" fill="#111D55">BMP</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 993 B |
@@ -20,6 +20,20 @@ export const SiteSettings: GlobalConfig = {
|
|||||||
description: `Current frontend fallback image: ${defaultSiteSettings.skylineImageUrl}`,
|
description: `Current frontend fallback image: ${defaultSiteSettings.skylineImageUrl}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'preistraegerPlaceholderImage',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Preisträger Platzhalterbild',
|
||||||
|
filterOptions: {
|
||||||
|
mimeType: {
|
||||||
|
contains: 'image',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
description: `Fallback image used when a Preisträger entry has no image. Current frontend default: ${defaultSiteSettings.preistraegerPlaceholderImageUrl}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'genericPageKicker',
|
name: 'genericPageKicker',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -108,6 +108,87 @@ export const Preistraeger: CollectionConfig = {
|
|||||||
name: 'website',
|
name: 'website',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'downloadZip',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Download ZIP',
|
||||||
|
filterOptions: {
|
||||||
|
mimeType: {
|
||||||
|
contains: 'zip',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
description: 'Optional ZIP file shown as a download button on the public Preisträger detail page.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'downloadLabel',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Download button label',
|
||||||
|
defaultValue: 'Pressepaket herunterladen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gallery',
|
||||||
|
label: 'Bildergalerie',
|
||||||
|
type: 'group',
|
||||||
|
admin: {
|
||||||
|
description: 'Optional image gallery shown on the public Preisträger detail page.',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'enabled',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Galerie anzeigen',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'heading',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Überschrift',
|
||||||
|
defaultValue: 'Bildergalerie',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'textarea',
|
||||||
|
label: 'Beschreibung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'images',
|
||||||
|
label: 'Bilder',
|
||||||
|
type: 'array',
|
||||||
|
dbName: 'preistraeger_gallery',
|
||||||
|
labels: {
|
||||||
|
singular: 'Bild',
|
||||||
|
plural: 'Bilder',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
type: 'upload',
|
||||||
|
relationTo: 'media',
|
||||||
|
label: 'Bild',
|
||||||
|
required: true,
|
||||||
|
filterOptions: {
|
||||||
|
mimeType: {
|
||||||
|
contains: 'image',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'imageAlt',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Alternativtext',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'caption',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Bildunterschrift',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'hasMedia',
|
name: 'hasMedia',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
|||||||
58
src/migrations/20260707_122300_preistraeger_zip_download.ts
Normal file
58
src/migrations/20260707_122300_preistraeger_zip_download.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||||
|
|
||||||
|
type MigrationDB = MigrateUpArgs['db']
|
||||||
|
|
||||||
|
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
|
||||||
|
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
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 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};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
|
||||||
|
if (!(await columnExists(db, tableName, columnName))) return
|
||||||
|
|
||||||
|
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await addColumn(db, 'preistraeger', 'download_zip_id', 'integer REFERENCES media(id)')
|
||||||
|
await addColumn(db, 'preistraeger', 'download_label', `text DEFAULT ${literal('Pressepaket herunterladen')}`)
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident('preistraeger_download_zip_idx')} ON ${ident('preistraeger')} (${ident('download_zip_id')});`))
|
||||||
|
|
||||||
|
await addColumn(db, '_preistraeger_v', 'version_download_zip_id', 'integer REFERENCES media(id)')
|
||||||
|
await addColumn(db, '_preistraeger_v', 'version_download_label', `text DEFAULT ${literal('Pressepaket herunterladen')}`)
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`CREATE INDEX IF NOT EXISTS ${ident('_preistraeger_v_version_version_download_zip_idx')} ON ${ident('_preistraeger_v')} (${ident('version_download_zip_id')});`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.run(sql.raw(`DROP INDEX IF EXISTS ${ident('preistraeger_download_zip_idx')};`))
|
||||||
|
await db.run(sql.raw(`DROP INDEX IF EXISTS ${ident('_preistraeger_v_version_version_download_zip_idx')};`))
|
||||||
|
|
||||||
|
await dropColumn(db, 'preistraeger', 'download_zip_id')
|
||||||
|
await dropColumn(db, 'preistraeger', 'download_label')
|
||||||
|
await dropColumn(db, '_preistraeger_v', 'version_download_zip_id')
|
||||||
|
await dropColumn(db, '_preistraeger_v', 'version_download_label')
|
||||||
|
}
|
||||||
92
src/migrations/20260707_123500_preistraeger_gallery.ts
Normal file
92
src/migrations/20260707_123500_preistraeger_gallery.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||||
|
|
||||||
|
type MigrationDB = MigrateUpArgs['db']
|
||||||
|
|
||||||
|
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
|
||||||
|
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
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 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};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
|
||||||
|
if (!(await columnExists(db, tableName, columnName))) return
|
||||||
|
|
||||||
|
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGalleryTable(db: MigrationDB, tableName: string, parentTableName: string, parentIDColumn = 'id') {
|
||||||
|
await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ${ident(tableName)} (
|
||||||
|
${ident('_order')} integer NOT NULL,
|
||||||
|
${ident('_parent_id')} integer NOT NULL,
|
||||||
|
${ident('id')} text PRIMARY KEY NOT NULL,
|
||||||
|
${ident('image_id')} integer NOT NULL,
|
||||||
|
${ident('image_alt')} text,
|
||||||
|
${ident('caption')} text,
|
||||||
|
FOREIGN KEY (${ident('image_id')}) REFERENCES ${ident('media')}(${ident('id')}) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident(parentTableName)}(${ident(parentIDColumn)}) ON UPDATE no action ON DELETE cascade
|
||||||
|
);`))
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_order_idx`)} ON ${ident(tableName)} (${ident('_order')});`))
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (${ident('_parent_id')});`))
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_image_idx`)} ON ${ident(tableName)} (${ident('image_id')});`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGalleryVersionTable(db: MigrationDB) {
|
||||||
|
await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ${ident('_preistraeger_gallery_v')} (
|
||||||
|
${ident('_order')} integer NOT NULL,
|
||||||
|
${ident('_parent_id')} integer NOT NULL,
|
||||||
|
${ident('id')} integer PRIMARY KEY NOT NULL,
|
||||||
|
${ident('image_id')} integer NOT NULL,
|
||||||
|
${ident('image_alt')} text,
|
||||||
|
${ident('caption')} text,
|
||||||
|
${ident('_uuid')} text,
|
||||||
|
FOREIGN KEY (${ident('image_id')}) REFERENCES ${ident('media')}(${ident('id')}) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('_preistraeger_v')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
|
||||||
|
);`))
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident('_preistraeger_gallery_v_order_idx')} ON ${ident('_preistraeger_gallery_v')} (${ident('_order')});`))
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident('_preistraeger_gallery_v_parent_id_idx')} ON ${ident('_preistraeger_gallery_v')} (${ident('_parent_id')});`))
|
||||||
|
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident('_preistraeger_gallery_v_image_idx')} ON ${ident('_preistraeger_gallery_v')} (${ident('image_id')});`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await addColumn(db, 'preistraeger', 'gallery_enabled', 'integer DEFAULT false')
|
||||||
|
await addColumn(db, 'preistraeger', 'gallery_heading', `text DEFAULT ${literal('Bildergalerie')}`)
|
||||||
|
await addColumn(db, 'preistraeger', 'gallery_description', 'text')
|
||||||
|
|
||||||
|
await addColumn(db, '_preistraeger_v', 'version_gallery_enabled', 'integer DEFAULT false')
|
||||||
|
await addColumn(db, '_preistraeger_v', 'version_gallery_heading', `text DEFAULT ${literal('Bildergalerie')}`)
|
||||||
|
await addColumn(db, '_preistraeger_v', 'version_gallery_description', 'text')
|
||||||
|
|
||||||
|
await createGalleryTable(db, 'preistraeger_gallery', 'preistraeger')
|
||||||
|
await createGalleryVersionTable(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('preistraeger_gallery')};`))
|
||||||
|
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident('_preistraeger_gallery_v')};`))
|
||||||
|
|
||||||
|
await dropColumn(db, 'preistraeger', 'gallery_enabled')
|
||||||
|
await dropColumn(db, 'preistraeger', 'gallery_heading')
|
||||||
|
await dropColumn(db, 'preistraeger', 'gallery_description')
|
||||||
|
await dropColumn(db, '_preistraeger_v', 'version_gallery_enabled')
|
||||||
|
await dropColumn(db, '_preistraeger_v', 'version_gallery_heading')
|
||||||
|
await dropColumn(db, '_preistraeger_v', 'version_gallery_description')
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
|
||||||
|
|
||||||
|
type MigrationDB = MigrateUpArgs['db']
|
||||||
|
|
||||||
|
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
|
||||||
|
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
|
||||||
|
if (!(await columnExists(db, 'site_settings', 'preistraeger_placeholder_image_id'))) {
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`ALTER TABLE ${ident('site_settings')} ADD ${ident('preistraeger_placeholder_image_id')} integer REFERENCES ${ident('media')}(${ident('id')});`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
sql.raw(
|
||||||
|
`CREATE INDEX IF NOT EXISTS ${ident('site_settings_preistraeger_placeholder_image_idx')} ON ${ident('site_settings')} (${ident('preistraeger_placeholder_image_id')});`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.run(sql.raw(`DROP INDEX IF EXISTS ${ident('site_settings_preistraeger_placeholder_image_idx')};`))
|
||||||
|
|
||||||
|
if (await columnExists(db, 'site_settings', 'preistraeger_placeholder_image_id')) {
|
||||||
|
await db.run(sql.raw(`ALTER TABLE ${ident('site_settings')} DROP COLUMN ${ident('preistraeger_placeholder_image_id')};`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import * as migration_20260630_172754_network_partner_logo_uploads from './20260
|
|||||||
import * as migration_20260630_175614_partners_collection from './20260630_175614_partners_collection';
|
import * as migration_20260630_175614_partners_collection from './20260630_175614_partners_collection';
|
||||||
import * as migration_20260630_182219_prune_unused_admin_collections from './20260630_182219_prune_unused_admin_collections';
|
import * as migration_20260630_182219_prune_unused_admin_collections from './20260630_182219_prune_unused_admin_collections';
|
||||||
import * as migration_20260701_163206_participation_phase_form_fields from './20260701_163206_participation_phase_form_fields';
|
import * as migration_20260701_163206_participation_phase_form_fields from './20260701_163206_participation_phase_form_fields';
|
||||||
|
import * as migration_20260707_122300_preistraeger_zip_download from './20260707_122300_preistraeger_zip_download';
|
||||||
|
import * as migration_20260707_123500_preistraeger_gallery from './20260707_123500_preistraeger_gallery';
|
||||||
|
import * as migration_20260707_124500_site_settings_preistraeger_placeholder from './20260707_124500_site_settings_preistraeger_placeholder';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -36,4 +39,19 @@ export const migrations = [
|
|||||||
down: migration_20260701_163206_participation_phase_form_fields.down,
|
down: migration_20260701_163206_participation_phase_form_fields.down,
|
||||||
name: '20260701_163206_participation_phase_form_fields',
|
name: '20260701_163206_participation_phase_form_fields',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260707_122300_preistraeger_zip_download.up,
|
||||||
|
down: migration_20260707_122300_preistraeger_zip_download.down,
|
||||||
|
name: '20260707_122300_preistraeger_zip_download',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260707_123500_preistraeger_gallery.up,
|
||||||
|
down: migration_20260707_123500_preistraeger_gallery.down,
|
||||||
|
name: '20260707_123500_preistraeger_gallery',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260707_124500_site_settings_preistraeger_placeholder.up,
|
||||||
|
down: migration_20260707_124500_site_settings_preistraeger_placeholder.down,
|
||||||
|
name: '20260707_124500_site_settings_preistraeger_placeholder',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2589,6 +2589,27 @@ export interface Preistraeger {
|
|||||||
location?: string | null;
|
location?: string | null;
|
||||||
industry?: string | null;
|
industry?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
/**
|
||||||
|
* Optional ZIP file shown as a download button on the public Preisträger detail page.
|
||||||
|
*/
|
||||||
|
downloadZip?: (number | null) | Media;
|
||||||
|
downloadLabel?: string | null;
|
||||||
|
/**
|
||||||
|
* Optional image gallery shown on the public Preisträger detail page.
|
||||||
|
*/
|
||||||
|
gallery?: {
|
||||||
|
enabled?: boolean | null;
|
||||||
|
heading?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
images?:
|
||||||
|
| {
|
||||||
|
image: number | Media;
|
||||||
|
imageAlt?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
};
|
||||||
hasMedia?: boolean | null;
|
hasMedia?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* Shared labels, explanatory copy, CTAs, and supporting media for the Preisträger detail route.
|
* Shared labels, explanatory copy, CTAs, and supporting media for the Preisträger detail route.
|
||||||
@@ -4849,6 +4870,23 @@ export interface PreistraegerSelect<T extends boolean = true> {
|
|||||||
location?: T;
|
location?: T;
|
||||||
industry?: T;
|
industry?: T;
|
||||||
website?: T;
|
website?: T;
|
||||||
|
downloadZip?: T;
|
||||||
|
downloadLabel?: T;
|
||||||
|
gallery?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
enabled?: T;
|
||||||
|
heading?: T;
|
||||||
|
description?: T;
|
||||||
|
images?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
image?: T;
|
||||||
|
imageAlt?: T;
|
||||||
|
caption?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
|
};
|
||||||
hasMedia?: T;
|
hasMedia?: T;
|
||||||
detailPage?:
|
detailPage?:
|
||||||
| T
|
| T
|
||||||
@@ -5500,6 +5538,10 @@ export interface SiteSetting {
|
|||||||
* Current frontend fallback image: /munich-skyline.jpg
|
* Current frontend fallback image: /munich-skyline.jpg
|
||||||
*/
|
*/
|
||||||
skylineImage?: (number | null) | Media;
|
skylineImage?: (number | null) | Media;
|
||||||
|
/**
|
||||||
|
* Fallback image used when a Preisträger entry has no image. Current frontend default: /images/preistraeger-placeholder.svg
|
||||||
|
*/
|
||||||
|
preistraegerPlaceholderImage?: (number | null) | Media;
|
||||||
genericPageKicker: string;
|
genericPageKicker: string;
|
||||||
genericPageTitle: string;
|
genericPageTitle: string;
|
||||||
genericPageDescription: string;
|
genericPageDescription: string;
|
||||||
@@ -5666,6 +5708,7 @@ export interface FooterSelect<T extends boolean = true> {
|
|||||||
*/
|
*/
|
||||||
export interface SiteSettingsSelect<T extends boolean = true> {
|
export interface SiteSettingsSelect<T extends boolean = true> {
|
||||||
skylineImage?: T;
|
skylineImage?: T;
|
||||||
|
preistraegerPlaceholderImage?: T;
|
||||||
genericPageKicker?: T;
|
genericPageKicker?: T;
|
||||||
genericPageTitle?: T;
|
genericPageTitle?: T;
|
||||||
genericPageDescription?: T;
|
genericPageDescription?: T;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { WINNERS, type Winner } from '@/spa/data/winners'
|
|||||||
import { useIsMobile } from '@/spa/hooks/useIsMobile'
|
import { useIsMobile } from '@/spa/hooks/useIsMobile'
|
||||||
import { Link } from '@/spa/router'
|
import { Link } from '@/spa/router'
|
||||||
import { aboutContent } from '@/spa/aboutContent'
|
import { aboutContent } from '@/spa/aboutContent'
|
||||||
|
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
|
||||||
|
|
||||||
const FF = '"IBM Plex Sans", sans-serif'
|
const FF = '"IBM Plex Sans", sans-serif'
|
||||||
const FB = '"Inter", sans-serif'
|
const FB = '"Inter", sans-serif'
|
||||||
@@ -83,7 +84,7 @@ function HighlightedText({ text, highlight }: { text: string; highlight: string
|
|||||||
|
|
||||||
const normalizeWinner = (
|
const normalizeWinner = (
|
||||||
doc: CmsRouteDoc | undefined,
|
doc: CmsRouteDoc | undefined,
|
||||||
fallbackImage: string,
|
placeholderImage: string,
|
||||||
fallbackWinner: typeof aboutContent.highlights.fallbackWinner,
|
fallbackWinner: typeof aboutContent.highlights.fallbackWinner,
|
||||||
): Winner | undefined => {
|
): Winner | undefined => {
|
||||||
if (!doc) return undefined
|
if (!doc) return undefined
|
||||||
@@ -94,7 +95,7 @@ const normalizeWinner = (
|
|||||||
category: String(doc.category || fallbackWinner.category),
|
category: String(doc.category || fallbackWinner.category),
|
||||||
year: Number(doc.year || aboutContent.highlights.year),
|
year: Number(doc.year || aboutContent.highlights.year),
|
||||||
type: String(doc.awardType || fallbackWinner.awardType),
|
type: String(doc.awardType || fallbackWinner.awardType),
|
||||||
img: mediaUrl(doc.image, fallbackImage),
|
img: mediaUrl(doc.image, placeholderImage),
|
||||||
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
|
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
|
||||||
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
|
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
|
||||||
quote: String(doc.quote || ''),
|
quote: String(doc.quote || ''),
|
||||||
@@ -110,16 +111,17 @@ const normalizeWinner = (
|
|||||||
const winnerFromRelationship = (
|
const winnerFromRelationship = (
|
||||||
entry: unknown,
|
entry: unknown,
|
||||||
allDocs: CmsRouteDoc[],
|
allDocs: CmsRouteDoc[],
|
||||||
fallbackImage: string,
|
placeholderImage: string,
|
||||||
fallbackWinner: typeof aboutContent.highlights.fallbackWinner,
|
fallbackWinner: typeof aboutContent.highlights.fallbackWinner,
|
||||||
) => {
|
) => {
|
||||||
if (entry && typeof entry === 'object') return normalizeWinner(entry as CmsRouteDoc, fallbackImage, fallbackWinner)
|
if (entry && typeof entry === 'object') return normalizeWinner(entry as CmsRouteDoc, placeholderImage, fallbackWinner)
|
||||||
const doc = allDocs.find((item) => String(item.id) === String(entry))
|
const doc = allDocs.find((item) => String(item.id) === String(entry))
|
||||||
return normalizeWinner(doc, fallbackImage, fallbackWinner)
|
return normalizeWinner(doc, placeholderImage, fallbackWinner)
|
||||||
}
|
}
|
||||||
|
|
||||||
const About: React.FC = () => {
|
const About: React.FC = () => {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
const placeholderImage = usePreistraegerPlaceholderImage()
|
||||||
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
|
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
|
||||||
const cmsWinners = useCmsCollection('preistraeger')
|
const cmsWinners = useCmsCollection('preistraeger')
|
||||||
const cmsPages = useCmsCollection('pages')
|
const cmsPages = useCmsCollection('pages')
|
||||||
@@ -135,10 +137,6 @@ const About: React.FC = () => {
|
|||||||
const participationPage = cmsPages.find(isParticipationPage)
|
const participationPage = cmsPages.find(isParticipationPage)
|
||||||
const participationAwardsGrid = (participationPage?.participation as { awardsGrid?: AwardsGridContent } | undefined)?.awardsGrid
|
const participationAwardsGrid = (participationPage?.participation as { awardsGrid?: AwardsGridContent } | undefined)?.awardsGrid
|
||||||
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
|
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
|
||||||
const highlightFallbackImage = mediaUrl(
|
|
||||||
highlights.fallbackImage,
|
|
||||||
`/images/${aboutContent.highlights.fallbackImageFilename}`,
|
|
||||||
)
|
|
||||||
const highlightFallbackWinner = useMemo(
|
const highlightFallbackWinner = useMemo(
|
||||||
() => ({ ...aboutContent.highlights.fallbackWinner, ...(highlights.fallbackWinner || {}) }),
|
() => ({ ...aboutContent.highlights.fallbackWinner, ...(highlights.fallbackWinner || {}) }),
|
||||||
[highlights],
|
[highlights],
|
||||||
@@ -146,7 +144,7 @@ const About: React.FC = () => {
|
|||||||
|
|
||||||
const highlightWinners = useMemo(() => {
|
const highlightWinners = useMemo(() => {
|
||||||
const selected = fallbackArray<unknown>(highlights.selectedWinners, [])
|
const selected = fallbackArray<unknown>(highlights.selectedWinners, [])
|
||||||
.map((entry) => winnerFromRelationship(entry, cmsWinners, highlightFallbackImage, highlightFallbackWinner))
|
.map((entry) => winnerFromRelationship(entry, cmsWinners, placeholderImage, highlightFallbackWinner))
|
||||||
.filter((winner): winner is Winner => Boolean(winner))
|
.filter((winner): winner is Winner => Boolean(winner))
|
||||||
|
|
||||||
if (selected.length) return selected.slice(0, 8)
|
if (selected.length) return selected.slice(0, 8)
|
||||||
@@ -155,12 +153,12 @@ const About: React.FC = () => {
|
|||||||
Boolean(winner) && winner?.year === Number(highlights.year || aboutContent.highlights.year)
|
Boolean(winner) && winner?.year === Number(highlights.year || aboutContent.highlights.year)
|
||||||
|
|
||||||
const cmsByYear = cmsWinners
|
const cmsByYear = cmsWinners
|
||||||
.map((winner) => normalizeWinner(winner, highlightFallbackImage, highlightFallbackWinner))
|
.map((winner) => normalizeWinner(winner, placeholderImage, highlightFallbackWinner))
|
||||||
.filter(isWinnerFromYear)
|
.filter(isWinnerFromYear)
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
|
|
||||||
return cmsByYear.length ? cmsByYear : FALLBACK_HIGHLIGHTS
|
return cmsByYear.length ? cmsByYear : FALLBACK_HIGHLIGHTS
|
||||||
}, [cmsWinners, highlightFallbackImage, highlightFallbackWinner, highlights])
|
}, [cmsWinners, highlightFallbackWinner, highlights, placeholderImage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRou
|
|||||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||||
import { homeContent } from '@/spa/homeContent'
|
import { homeContent } from '@/spa/homeContent'
|
||||||
import { netzwerkContent } from '@/spa/netzwerkContent'
|
import { netzwerkContent } from '@/spa/netzwerkContent'
|
||||||
|
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'
|
||||||
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
|
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'
|
||||||
|
|
||||||
const FF = '"IBM Plex Sans", sans-serif';
|
const FF = '"IBM Plex Sans", sans-serif';
|
||||||
@@ -220,7 +221,7 @@ function HomeWinnerCard({
|
|||||||
item.name === winner?.name,
|
item.name === winner?.name,
|
||||||
);
|
);
|
||||||
const title = fallbackText(winner?.title, fallbackText(winner?.name, staticWinner?.name || fallbackTitle));
|
const title = fallbackText(winner?.title, fallbackText(winner?.name, staticWinner?.name || fallbackTitle));
|
||||||
const imageSrc = mediaUrl(winner?.image, winner?.img || staticWinner?.img || fallbackImage);
|
const imageSrc = winner?.img || mediaUrl(winner?.image, fallbackImage);
|
||||||
const href = winner?.spaPath || (winner?.slug ? `/preistraeger/${winner.slug}` : staticWinner ? `/preistraeger/${staticWinner.slug}` : '/preistraeger');
|
const href = winner?.spaPath || (winner?.slug ? `/preistraeger/${winner.slug}` : staticWinner ? `/preistraeger/${staticWinner.slug}` : '/preistraeger');
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -252,6 +253,7 @@ const Home: React.FC = () => {
|
|||||||
const [showVideo, setShowVideo] = useState(false);
|
const [showVideo, setShowVideo] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const placeholderImage = usePreistraegerPlaceholderImage();
|
||||||
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
|
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
|
||||||
const netzwerkPage = useCmsCollection('pages').find((page) => isNetzwerkPage(page as CmsRecord)) as
|
const netzwerkPage = useCmsCollection('pages').find((page) => isNetzwerkPage(page as CmsRecord)) as
|
||||||
| ({ netzwerk?: { partners?: NetzwerkPartnersCms } } & CmsRecord)
|
| ({ netzwerk?: { partners?: NetzwerkPartnersCms } } & CmsRecord)
|
||||||
@@ -302,7 +304,7 @@ const Home: React.FC = () => {
|
|||||||
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
|
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
|
||||||
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
|
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
|
||||||
const finalSectionBenefits = showApplicationForm ? applicationBenefits : newsletterCopy.benefits;
|
const finalSectionBenefits = showApplicationForm ? applicationBenefits : newsletterCopy.benefits;
|
||||||
const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`);
|
const winnerFallbackImage = placeholderImage;
|
||||||
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
|
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
|
||||||
const videoSrc = mediaUrl(videoModal.video, '');
|
const videoSrc = mediaUrl(videoModal.video, '');
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
|||||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent';
|
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent';
|
||||||
|
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder';
|
||||||
|
|
||||||
const FF = '"IBM Plex Sans", sans-serif';
|
const FF = '"IBM Plex Sans", sans-serif';
|
||||||
const NAVY = '#111D55';
|
const NAVY = '#111D55';
|
||||||
@@ -25,7 +26,7 @@ const fallbackText = (value: unknown, fallback: string) => typeof value === 'str
|
|||||||
|
|
||||||
const normalizeWinner = (
|
const normalizeWinner = (
|
||||||
doc: CmsRouteDoc,
|
doc: CmsRouteDoc,
|
||||||
fallbackImage: string,
|
placeholderImage: string,
|
||||||
fallbackWinner: typeof preistraegerIndexContent.card.fallbackWinner,
|
fallbackWinner: typeof preistraegerIndexContent.card.fallbackWinner,
|
||||||
): Winner => ({
|
): Winner => ({
|
||||||
id: String(doc.id),
|
id: String(doc.id),
|
||||||
@@ -34,7 +35,7 @@ const normalizeWinner = (
|
|||||||
category: String(doc.category || fallbackWinner.category || preistraegerIndexContent.card.fallbackWinner.category),
|
category: String(doc.category || fallbackWinner.category || preistraegerIndexContent.card.fallbackWinner.category),
|
||||||
year: Number(doc.year || new Date().getFullYear()),
|
year: Number(doc.year || new Date().getFullYear()),
|
||||||
type: String(doc.awardType || fallbackWinner.awardType || preistraegerIndexContent.card.fallbackWinner.awardType),
|
type: String(doc.awardType || fallbackWinner.awardType || preistraegerIndexContent.card.fallbackWinner.awardType),
|
||||||
img: docImageUrl(doc, fallbackImage),
|
img: docImageUrl(doc, placeholderImage),
|
||||||
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
|
shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''),
|
||||||
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
|
longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''),
|
||||||
quote: String(doc.quote || ''),
|
quote: String(doc.quote || ''),
|
||||||
@@ -48,6 +49,7 @@ const normalizeWinner = (
|
|||||||
|
|
||||||
export default function Preistraeger() {
|
export default function Preistraeger() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const placeholderImage = usePreistraegerPlaceholderImage();
|
||||||
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
|
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
|
||||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||||
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
|
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
|
||||||
@@ -56,15 +58,11 @@ export default function Preistraeger() {
|
|||||||
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
|
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
|
||||||
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
|
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
|
||||||
const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) };
|
const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) };
|
||||||
const cardFallbackImage = useMemo(
|
|
||||||
() => mediaUrl(card.fallbackImage, `/images/${preistraegerIndexContent.card.fallbackImageFilename}`),
|
|
||||||
[card],
|
|
||||||
);
|
|
||||||
const winners = useMemo(() => {
|
const winners = useMemo(() => {
|
||||||
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
|
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
|
||||||
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, cardFallbackImage, fallbackWinner)).filter((winner) => winner.slug);
|
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
|
||||||
return WINNERS;
|
return WINNERS;
|
||||||
}, [card, cardFallbackImage, cmsPreistraeger]);
|
}, [card, cmsPreistraeger, placeholderImage]);
|
||||||
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
|
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
|
||||||
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
|
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
|
||||||
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
|
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useParams, Navigate } from '@/spa/router';
|
import { Link, useParams, Navigate } from '@/spa/router';
|
||||||
import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight } from 'lucide-react';
|
import { Trophy, ArrowLeft, MapPin, Building2, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
|
||||||
import { WINNERS } from '@/spa/data/winners';
|
import { WINNERS } from '@/spa/data/winners';
|
||||||
import { useCmsRoute } from '@/spa/cmsRoute';
|
import { useCmsRoute } from '@/spa/cmsRoute';
|
||||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||||
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent';
|
import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent';
|
||||||
|
import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder';
|
||||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||||
|
|
||||||
const FF = '"IBM Plex Sans", sans-serif';
|
const FF = '"IBM Plex Sans", sans-serif';
|
||||||
@@ -21,18 +22,38 @@ type PreistraegerDetailCms = Partial<typeof preistraegerDetailContent> & {
|
|||||||
sideImage?: Partial<typeof preistraegerDetailContent.sideImage> & { image?: unknown }
|
sideImage?: Partial<typeof preistraegerDetailContent.sideImage> & { image?: unknown }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GalleryImageCms = {
|
||||||
|
id?: string | null
|
||||||
|
image?: unknown
|
||||||
|
imageAlt?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreistraegerGalleryCms = {
|
||||||
|
enabled?: boolean | null
|
||||||
|
heading?: string | null
|
||||||
|
description?: string | null
|
||||||
|
images?: GalleryImageCms[] | null
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||||
|
|
||||||
|
const mediaFilename = (media: unknown) => {
|
||||||
|
if (!media || typeof media !== 'object') return undefined;
|
||||||
|
const filename = (media as { filename?: unknown }).filename;
|
||||||
|
return typeof filename === 'string' && filename.length > 0 ? filename : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export default function PreistraegerDetail() {
|
export default function PreistraegerDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const route = useCmsRoute();
|
const route = useCmsRoute();
|
||||||
|
const placeholderImage = usePreistraegerPlaceholderImage();
|
||||||
const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined;
|
const cmsWinner = route?.collection === 'preistraeger' ? route.doc : undefined;
|
||||||
const cmsDetail = {
|
const cmsDetail = {
|
||||||
...preistraegerDetailContent,
|
...preistraegerDetailContent,
|
||||||
...((cmsWinner?.detailPage || {}) as PreistraegerDetailCms),
|
...((cmsWinner?.detailPage || {}) as PreistraegerDetailCms),
|
||||||
} as PreistraegerDetailCms;
|
} as PreistraegerDetailCms;
|
||||||
const fallbackWinner = cmsDetail.fallbackWinner || preistraegerDetailContent.fallbackWinner;
|
const fallbackWinner = cmsDetail.fallbackWinner || preistraegerDetailContent.fallbackWinner;
|
||||||
const winnerFallbackImage = mediaUrl(cmsDetail.fallbackImage, `/images/${preistraegerDetailContent.fallbackImageFilename}`);
|
|
||||||
const winner = cmsWinner ? {
|
const winner = cmsWinner ? {
|
||||||
id: String(cmsWinner.id),
|
id: String(cmsWinner.id),
|
||||||
slug: String(cmsWinner.slug || slug),
|
slug: String(cmsWinner.slug || slug),
|
||||||
@@ -40,7 +61,7 @@ export default function PreistraegerDetail() {
|
|||||||
category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category),
|
category: String(cmsWinner.category || fallbackWinner.category || preistraegerDetailContent.fallbackWinner.category),
|
||||||
year: Number(cmsWinner.year || new Date().getFullYear()),
|
year: Number(cmsWinner.year || new Date().getFullYear()),
|
||||||
type: String(cmsWinner.awardType || fallbackWinner.awardType || preistraegerDetailContent.fallbackWinner.awardType),
|
type: String(cmsWinner.awardType || fallbackWinner.awardType || preistraegerDetailContent.fallbackWinner.awardType),
|
||||||
img: docImageUrl(cmsWinner, winnerFallbackImage),
|
img: docImageUrl(cmsWinner, placeholderImage),
|
||||||
shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''),
|
shortDesc: String(cmsWinner.shortDesc || cmsWinner.description || cmsWinner.meta?.description || ''),
|
||||||
longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || fallbackWinner.longDesc || preistraegerDetailContent.fallbackWinner.longDesc),
|
longDesc: String(cmsWinner.longDesc || cmsWinner.description || cmsWinner.meta?.description || fallbackWinner.longDesc || preistraegerDetailContent.fallbackWinner.longDesc),
|
||||||
quote: String(cmsWinner.quote || fallbackWinner.quote || preistraegerDetailContent.fallbackWinner.quote),
|
quote: String(cmsWinner.quote || fallbackWinner.quote || preistraegerDetailContent.fallbackWinner.quote),
|
||||||
@@ -61,6 +82,23 @@ export default function PreistraegerDetail() {
|
|||||||
const facts = { ...preistraegerDetailContent.facts, ...(detail.facts || {}) };
|
const facts = { ...preistraegerDetailContent.facts, ...(detail.facts || {}) };
|
||||||
const websiteCta = { ...preistraegerDetailContent.websiteCta, ...(detail.websiteCta || {}) };
|
const websiteCta = { ...preistraegerDetailContent.websiteCta, ...(detail.websiteCta || {}) };
|
||||||
const backLink = { ...preistraegerDetailContent.backLink, ...(detail.backLink || {}) };
|
const backLink = { ...preistraegerDetailContent.backLink, ...(detail.backLink || {}) };
|
||||||
|
const downloadHref = mediaUrl(cmsWinner?.downloadZip, '');
|
||||||
|
const downloadFilename = mediaFilename(cmsWinner?.downloadZip);
|
||||||
|
const downloadLabel = fallbackText(cmsWinner?.downloadLabel, 'Pressepaket herunterladen');
|
||||||
|
const gallery = (cmsWinner?.gallery || {}) as PreistraegerGalleryCms;
|
||||||
|
const galleryImages = Array.isArray(gallery.images)
|
||||||
|
? gallery.images
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id || mediaFilename(item.image) || String(mediaUrl(item.image, '')),
|
||||||
|
src: mediaUrl(item.image, ''),
|
||||||
|
alt: mediaAlt(item.image, fallbackText(item.imageAlt, fallbackText(item.caption, winner?.name || 'Galeriebild'))),
|
||||||
|
caption: fallbackText(item.caption, ''),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.src)
|
||||||
|
: [];
|
||||||
|
const [activeGalleryIndex, setActiveGalleryIndex] = React.useState<number | undefined>(undefined);
|
||||||
|
const activeGalleryImage = typeof activeGalleryIndex === 'number' ? galleryImages[activeGalleryIndex] : undefined;
|
||||||
|
const showGallery = Boolean(gallery.enabled) && galleryImages.length > 0;
|
||||||
|
|
||||||
if (!winner) return <Navigate to={preistraegerDetailContent.notFoundRedirect} replace />;
|
if (!winner) return <Navigate to={preistraegerDetailContent.notFoundRedirect} replace />;
|
||||||
|
|
||||||
@@ -187,9 +225,155 @@ export default function PreistraegerDetail() {
|
|||||||
>
|
>
|
||||||
<Globe size={16} /> {fallbackText(websiteCta.label, preistraegerDetailContent.websiteCta.label)}
|
<Globe size={16} /> {fallbackText(websiteCta.label, preistraegerDetailContent.websiteCta.label)}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{downloadHref ? (
|
||||||
|
<a
|
||||||
|
href={downloadHref}
|
||||||
|
download={downloadFilename}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
|
fontFamily: FF, fontSize: 16, fontWeight: 700,
|
||||||
|
color: '#101828', border: `2px solid ${GOLD}`,
|
||||||
|
padding: '12px 20px', textDecoration: 'none',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||||
|
transition: 'all 0.2s', borderRadius: 0, overflowWrap: 'anywhere',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.background = GOLD; el.style.color = '#101828'; }}
|
||||||
|
onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.background = 'transparent'; el.style.color = NAVY; }}
|
||||||
|
>
|
||||||
|
<Download size={16} /> {downloadLabel}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showGallery ? (
|
||||||
|
<section style={{ borderTop: `1px solid ${BORDER}`, background: '#fff' }}>
|
||||||
|
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px' }}>
|
||||||
|
<div style={{ marginBottom: 28, maxWidth: 760 }}>
|
||||||
|
<h2 style={{ fontFamily: FF, fontSize: isMobile ? 26 : 34, fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.02em', margin: '0 0 12px' }}>
|
||||||
|
{fallbackText(gallery.heading, 'Bildergalerie')}
|
||||||
|
</h2>
|
||||||
|
{gallery.description ? (
|
||||||
|
<p style={{ fontFamily: FB, fontSize: 17, color: GRAY, lineHeight: 1.7, margin: 0 }}>
|
||||||
|
{gallery.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
||||||
|
gap: isMobile ? 14 : 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{galleryImages.map((image, index) => (
|
||||||
|
<button
|
||||||
|
key={image.id || `${image.src}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveGalleryIndex(index)}
|
||||||
|
aria-label={`${image.alt} öffnen`}
|
||||||
|
style={{
|
||||||
|
appearance: 'none',
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
background: '#fff',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'block', aspectRatio: '4/3', background: BG_ALT, overflow: 'hidden' }}>
|
||||||
|
<Image
|
||||||
|
unoptimized
|
||||||
|
src={image.src}
|
||||||
|
alt={image.alt}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{image.caption ? (
|
||||||
|
<span style={{ display: 'block', fontFamily: FB, fontSize: 15, lineHeight: 1.5, color: '#333', padding: '12px 14px', overflowWrap: 'anywhere' }}>
|
||||||
|
{image.caption}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeGalleryImage ? (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={activeGalleryImage.alt}
|
||||||
|
onClick={() => setActiveGalleryIndex(undefined)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: 'rgba(3,9,58,0.92)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: isMobile ? 18 : 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveGalleryIndex(undefined)}
|
||||||
|
aria-label="Galerie schließen"
|
||||||
|
style={{ position: 'absolute', top: 18, right: 18, width: 44, height: 44, border: `1px solid rgba(255,255,255,0.28)`, background: 'rgba(255,255,255,0.08)', color: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<X size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{galleryImages.length > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setActiveGalleryIndex((activeGalleryIndex ?? 0) === 0 ? galleryImages.length - 1 : (activeGalleryIndex ?? 0) - 1);
|
||||||
|
}}
|
||||||
|
aria-label="Vorheriges Bild"
|
||||||
|
style={{ position: 'absolute', left: isMobile ? 12 : 24, top: '50%', transform: 'translateY(-50%)', width: 44, height: 44, border: `1px solid rgba(255,255,255,0.28)`, background: 'rgba(255,255,255,0.08)', color: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 'min(1120px, 100%)', maxHeight: '86vh', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<Image
|
||||||
|
unoptimized
|
||||||
|
src={activeGalleryImage.src}
|
||||||
|
alt={activeGalleryImage.alt}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: activeGalleryImage.caption ? '76vh' : '84vh', objectFit: 'contain', display: 'block' }}
|
||||||
|
/>
|
||||||
|
{activeGalleryImage.caption ? (
|
||||||
|
<p style={{ fontFamily: FB, fontSize: 16, lineHeight: 1.6, color: '#fff', margin: 0, textAlign: 'center', overflowWrap: 'anywhere' }}>
|
||||||
|
{activeGalleryImage.caption}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{galleryImages.length > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setActiveGalleryIndex((activeGalleryIndex ?? 0) === galleryImages.length - 1 ? 0 : (activeGalleryIndex ?? 0) + 1);
|
||||||
|
}}
|
||||||
|
aria-label="Nächstes Bild"
|
||||||
|
style={{ position: 'absolute', right: isMobile ? 12 : 24, top: '50%', transform: 'translateY(-50%)', width: 44, height: 44, border: `1px solid rgba(255,255,255,0.28)`, background: 'rgba(255,255,255,0.08)', color: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<div style={{ padding: isMobile ? '32px 24px 48px' : '32px 80px 64px', borderTop: `1px solid ${BORDER}` }}>
|
<div style={{ padding: isMobile ? '32px 24px 48px' : '32px 80px 64px', borderTop: `1px solid ${BORDER}` }}>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
11
src/spa/preistraegerPlaceholder.ts
Normal file
11
src/spa/preistraegerPlaceholder.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { mediaUrl } from '@/spa/cmsMediaField'
|
||||||
|
import { useCmsRoute } from '@/spa/cmsRoute'
|
||||||
|
import { defaultSiteSettings } from '@/spa/siteSettings'
|
||||||
|
|
||||||
|
export const PREISTRAEGER_PLACEHOLDER_IMAGE = defaultSiteSettings.preistraegerPlaceholderImageUrl
|
||||||
|
|
||||||
|
export function usePreistraegerPlaceholderImage() {
|
||||||
|
const siteSettings = useCmsRoute()?.siteSettings || defaultSiteSettings
|
||||||
|
|
||||||
|
return mediaUrl(siteSettings.preistraegerPlaceholderImage, siteSettings.preistraegerPlaceholderImageUrl)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ export type SpaSiteSettingsData = {
|
|||||||
genericPageKicker: string
|
genericPageKicker: string
|
||||||
genericPageTitle: string
|
genericPageTitle: string
|
||||||
genericPageDescription: string
|
genericPageDescription: string
|
||||||
|
preistraegerPlaceholderImage?: unknown
|
||||||
|
preistraegerPlaceholderImageUrl: string
|
||||||
skylineImage?: unknown
|
skylineImage?: unknown
|
||||||
skylineImageUrl: string
|
skylineImageUrl: string
|
||||||
}
|
}
|
||||||
@@ -10,6 +12,7 @@ export const defaultSiteSettings: SpaSiteSettingsData = {
|
|||||||
genericPageKicker: 'CMS',
|
genericPageKicker: 'CMS',
|
||||||
genericPageTitle: 'Seite',
|
genericPageTitle: 'Seite',
|
||||||
genericPageDescription: 'Diese Route wird aus Payload CMS geladen. Ergänze Inhalte im entsprechenden Collection-Dokument.',
|
genericPageDescription: 'Diese Route wird aus Payload CMS geladen. Ergänze Inhalte im entsprechenden Collection-Dokument.',
|
||||||
|
preistraegerPlaceholderImageUrl: '/images/preistraeger-placeholder.svg',
|
||||||
skylineImageUrl: '/munich-skyline.jpg',
|
skylineImageUrl: '/munich-skyline.jpg',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user