Merge pull request #450 from the-luap/feat/footer-overhaul-441-440

feat(footer): hideable legal links + socials + promo banner (#441 + #440)
This commit is contained in:
Paul Nothaft
2026-05-10 21:57:35 +02:00
committed by GitHub
25 changed files with 778 additions and 93 deletions
@@ -0,0 +1,119 @@
/**
* Migration 089: Footer overhaul (#441 + #440).
*
* Three concerns, all in the gallery footer area:
*
* 1. Per-CMS-page "Show in footer" toggle (#441 part a). Lets admins
* hide legal-link entries (Impressum, Datenschutz, etc.) when the
* target jurisdiction doesn't require them. Default TRUE so existing
* installs see no change.
*
* 2. Social links in the footer (#441 part b). Five branding settings
* for the canonical photographer-relevant networks: Facebook,
* Instagram, WhatsApp, X/Twitter, YouTube. All optional strings.
*
* 3. Promotional markdown slot above/below the footer (#440). Global
* default + per-event override with a three-way mode switch:
* - inherit (default): use the global, render nothing if global empty
* - custom: render the per-event markdown
* - off: suppress entirely for this event regardless of global
*
* Markdown only (no raw HTML) — the rendering pipeline is
* `marked → DOMPurify` on the frontend so admins can format text
* without opening an XSS surface.
*/
exports.up = async function(knex) {
console.log('Running migration: 089_footer_overhaul');
// 1. cms_pages.show_in_footer
const hasShowInFooter = await knex.schema.hasColumn('cms_pages', 'show_in_footer');
if (!hasShowInFooter) {
await knex.schema.alterTable('cms_pages', (table) => {
table.boolean('show_in_footer').notNullable().defaultTo(true);
});
console.log(' added cms_pages.show_in_footer (default true)');
} else {
console.log(' cms_pages.show_in_footer already exists, skipping');
}
// 2. events.promo_mode + events.promo_markdown
const hasPromoMode = await knex.schema.hasColumn('events', 'promo_mode');
if (!hasPromoMode) {
await knex.schema.alterTable('events', (table) => {
table.string('promo_mode', 16).notNullable().defaultTo('inherit');
});
console.log(' added events.promo_mode (default "inherit")');
} else {
console.log(' events.promo_mode already exists, skipping');
}
const hasPromoMarkdown = await knex.schema.hasColumn('events', 'promo_markdown');
if (!hasPromoMarkdown) {
await knex.schema.alterTable('events', (table) => {
table.text('promo_markdown').nullable();
});
console.log(' added events.promo_markdown (nullable text)');
} else {
console.log(' events.promo_markdown already exists, skipping');
}
// 3. New branding settings rows. Use the same `branding_<name>`
// convention as the existing 21 branding rows (verified via
// SELECT setting_key FROM app_settings WHERE setting_key LIKE 'branding_%').
const newSettings = [
{ setting_key: 'branding_facebook_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_instagram_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_whatsapp_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_twitter_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_youtube_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
{ setting_key: 'branding_promo_markdown', setting_value: JSON.stringify(''), setting_type: 'branding' },
// 'above_footer' | 'below_footer' (string instead of enum so we can
// expand without a schema change later).
{ setting_key: 'branding_promo_position', setting_value: JSON.stringify('above_footer'), setting_type: 'branding' },
];
for (const setting of newSettings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
console.log(` ensured ${newSettings.length} branding rows`);
console.log('Migration 089_footer_overhaul completed');
};
exports.down = async function(knex) {
console.log('Rollback: 089_footer_overhaul');
if (await knex.schema.hasColumn('cms_pages', 'show_in_footer')) {
await knex.schema.alterTable('cms_pages', (table) => {
table.dropColumn('show_in_footer');
});
}
if (await knex.schema.hasColumn('events', 'promo_mode')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('promo_mode');
});
}
if (await knex.schema.hasColumn('events', 'promo_markdown')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('promo_markdown');
});
}
await knex('app_settings')
.whereIn('setting_key', [
'branding_facebook_url',
'branding_instagram_url',
'branding_whatsapp_url',
'branding_twitter_url',
'branding_youtube_url',
'branding_promo_markdown',
'branding_promo_position',
])
.del();
};
+5 -1
View File
@@ -73,7 +73,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('content_de').optional().isString(),
body('logo_url').optional({ nullable: true }).isString(),
body('use_external_url').optional().isBoolean(),
body('external_url').optional({ nullable: true }).isString()
body('external_url').optional({ nullable: true }).isString(),
body('show_in_footer').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -127,6 +128,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
updateFields.external_url = trimmed || null;
}
if (Object.prototype.hasOwnProperty.call(req.body, 'show_in_footer')) {
updateFields.show_in_footer = !!req.body.show_in_footer;
}
await db('cms_pages').where('slug', slug).update(updateFields);
+29 -2
View File
@@ -393,7 +393,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
]),
// Per-event promotional override (#440). Three-way mode:
// inherit → fall back to global branding_promo_markdown
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString()
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -1100,7 +1106,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
]),
// Per-event promotional override (#440). Three-way mode:
// inherit → fall back to global branding_promo_markdown
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
body('promo_markdown').optional({ nullable: true }).isString()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -1251,6 +1263,21 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
}
// Per-event promotional override (#440). Normalize promo_markdown to
// NULL when mode is anything other than 'custom' so we don't carry
// stale text after the admin switches modes. Empty markdown also
// becomes NULL.
if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode')
|| Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
const mode = updates.promo_mode;
if (mode && mode !== 'custom') {
updates.promo_markdown = null;
} else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) {
const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : '';
updates.promo_markdown = md || null;
}
}
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the
+27 -2
View File
@@ -220,7 +220,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_hero,
logo_display_mode,
hide_powered_by,
force_color_mode
force_color_mode,
// Footer overhaul (#441 + #440). Socials are URL strings (empty
// hides the icon). Promo content is markdown (rendered via
// marked → DOMPurify on the frontend, no raw HTML accepted).
facebook_url,
instagram_url,
whatsapp_url,
twitter_url,
youtube_url,
promo_markdown,
promo_position
} = req.body;
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
@@ -233,6 +243,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
// Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash();
// Normalize promo_position: only 'above_footer' | 'below_footer' valid.
const normalizedPromoPosition = promo_position === 'below_footer'
? 'below_footer'
: 'above_footer';
const brandingSettings = {
company_name,
company_tagline,
@@ -252,7 +267,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_hero,
logo_display_mode,
hide_powered_by,
force_color_mode: normalizedForceColorMode
force_color_mode: normalizedForceColorMode,
// Footer overhaul (#441 + #440). String fields normalize empty/
// undefined → '' so the column is always a known type. Only persist
// when the request actually included the key (partial PUTs).
...(facebook_url !== undefined && { facebook_url: String(facebook_url || '').trim() }),
...(instagram_url !== undefined && { instagram_url: String(instagram_url || '').trim() }),
...(whatsapp_url !== undefined && { whatsapp_url: String(whatsapp_url || '').trim() }),
...(twitter_url !== undefined && { twitter_url: String(twitter_url || '').trim() }),
...(youtube_url !== undefined && { youtube_url: String(youtube_url || '').trim() }),
...(promo_markdown !== undefined && { promo_markdown: typeof promo_markdown === 'string' ? promo_markdown : '' }),
...(promo_position !== undefined && { promo_position: normalizedPromoPosition })
};
// Handle favicon deletion if empty string or null is provided
+11 -2
View File
@@ -127,7 +127,12 @@ router.get('/:slug/info', async (req, res) => {
'hero_divider_style',
'hero_image_anchor',
'is_draft',
'default_photo_sort'
'default_photo_sort',
// Per-event promotional override (#440). Resolution into a
// ready-to-render markdown string happens below so the
// frontend doesn't have to know about modes.
'promo_mode',
'promo_markdown'
)
.first();
@@ -187,7 +192,11 @@ router.get('/:slug/info', async (req, res) => {
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
// Per-event promotional override (#440). Frontend resolves
// 'inherit' against branding_promo_markdown from public settings.
promo_mode: event.promo_mode || 'inherit',
promo_markdown: event.promo_markdown || null
});
} catch (error) {
console.error('Error fetching gallery info:', error);
+5
View File
@@ -33,6 +33,11 @@ router.get('/pages/:slug', async (req, res) => {
// toggle can be flipped back on, but it shouldn't leak via the API).
use_external_url: !!page.use_external_url,
external_url: page.use_external_url && page.external_url ? page.external_url : null,
// Footer visibility (#441). Default true for legacy rows; admins
// can hide a CMS page from the gallery footer when their
// jurisdiction doesn't require it (e.g. impressum / datenschutz
// outside DE/AT).
show_in_footer: page.show_in_footer !== false,
updated_at: page.updated_at
});
} catch (error) {
+12
View File
@@ -65,6 +65,18 @@ router.get('/', async (req, res) => {
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
// Footer overhaul (#441 + #440). Empty strings hide each social
// icon individually; promo_markdown empty hides the slot for
// events in 'inherit' mode.
branding_facebook_url: settingsObject.branding_facebook_url || '',
branding_instagram_url: settingsObject.branding_instagram_url || '',
branding_whatsapp_url: settingsObject.branding_whatsapp_url || '',
branding_twitter_url: settingsObject.branding_twitter_url || '',
branding_youtube_url: settingsObject.branding_youtube_url || '',
branding_promo_markdown: settingsObject.branding_promo_markdown || '',
branding_promo_position: settingsObject.branding_promo_position === 'below_footer'
? 'below_footer'
: 'above_footer',
// Force a specific color mode site-wide. When set, the user toggle
// is hidden and the value overrides per-theme/system preference.
// Allowed values: 'dark' | 'light' | null (null = no force).