From 96fe478bf87a3350185206b3d6f15133138b995d Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 6 Jul 2026 11:20:36 +0200 Subject: [PATCH 1/2] fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: the global branding_logo_display_hero toggle was only a creation-time default — snapshotted into each event's hero_logo_visible column at creation and never consulted again. Disabling it did nothing to existing galleries (the reporter's bug), and the two gallery render paths disagreed (GalleryLayout read the global, HeroHeader read the per-event snapshot). Now: NULL per-event hero_logo_visible = 'inherit the global toggle'; an explicit true/false is a per-gallery override. - Migration 152: make events.hero_logo_visible nullable and NULL out the defaulted rows so existing galleries follow the global going forward. Deliberate per-gallery hides () are preserved. - Creation stores NULL unless the admin explicitly sets it; the update path preserves NULL. - gallery.js resolves per-event ?? global (branding_logo_display_hero, default true) and sends the EFFECTIVE value on both gallery responses. - Both frontend render paths now consume that resolved value (GalleryLayout gets it via a new heroLogoVisible prop). - Admin per-event control is now tri-state: Use branding default / Always show / Always hide (en + de). Verified: SQLite migration + live resolution (inherit follows global both ways; override wins both ways); PG migration SQL dry-run; the admin tri-state renders 'Use branding default' for an inherited event; tsc clean, 106 adminEvents+gallery tests pass, build green. --- .../core/152_hero_logo_visible_inherit.js | 52 +++++++++++++++++++ backend/src/routes/adminEvents/crud.js | 19 +++++-- backend/src/routes/gallery.js | 19 +++++-- .../src/components/gallery/GalleryLayout.tsx | 8 +++ .../src/components/gallery/GalleryView.tsx | 1 + frontend/src/i18n/locales/de.json | 3 ++ frontend/src/i18n/locales/en.json | 3 ++ frontend/src/pages/admin/EventDetailsPage.tsx | 5 +- .../event-details/EventInformationCard.tsx | 35 +++++++++---- .../src/pages/admin/event-details/types.ts | 8 +-- frontend/src/types/index.ts | 4 +- 11 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/core/152_hero_logo_visible_inherit.js diff --git a/backend/migrations/core/152_hero_logo_visible_inherit.js b/backend/migrations/core/152_hero_logo_visible_inherit.js new file mode 100644 index 00000000..0fb1e74a --- /dev/null +++ b/backend/migrations/core/152_hero_logo_visible_inherit.js @@ -0,0 +1,52 @@ +/** + * Migration 152: make events.hero_logo_visible NULL-able so NULL means + * "inherit the global branding_logo_display_hero setting" (#756). + * + * Before: hero_logo_visible was `boolean NOT NULL DEFAULT true`, and every + * event got a concrete true/false snapshotted at creation. The global + * "Show logo in hero section" toggle (branding_logo_display_hero) was only a + * creation-time default and never affected existing galleries — so disabling + * it did nothing to already-published galleries. + * + * After: NULL = inherit. gallery read-resolution falls back to the global + * setting when the per-event value is NULL, so the global toggle controls + * every gallery that hasn't been deliberately overridden per-event. + * + * Data backfill: NULL out the DEFAULTED `true` rows so they start inheriting + * the global. A deliberate per-gallery hide (`false`) is kept — we can't tell a + * defaulted-true from a chosen-true, but `false` is almost always a conscious + * "hide it here", and nulling it could silently re-show a hidden logo. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return; + + const client = (knex.client.config.client || '').toLowerCase(); + if (client === 'pg' || client === 'postgresql') { + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP DEFAULT'); + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP NOT NULL'); + } else { + // SQLite (and others): knex recreates the table without the NOT NULL/default. + await knex.schema.alterTable('events', (t) => { + t.boolean('hero_logo_visible').nullable().alter(); + }); + } + + // Existing defaulted-`true` galleries now inherit the global toggle. + await knex('events').where('hero_logo_visible', true).update({ hero_logo_visible: null }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return; + // Re-materialise NULLs as the old default (true) before restoring NOT NULL. + await knex('events').whereNull('hero_logo_visible').update({ hero_logo_visible: true }); + + const client = (knex.client.config.client || '').toLowerCase(); + if (client === 'pg' || client === 'postgresql') { + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET DEFAULT true'); + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET NOT NULL'); + } else { + await knex.schema.alterTable('events', (t) => { + t.boolean('hero_logo_visible').notNullable().defaultTo(true).alter(); + }); + } +}; diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 67e985a0..0cdb4c1a 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -339,7 +339,13 @@ module.exports = (router) => { // Get branding defaults for hero logo settings (Feature 7: Branding Inheritance) const brandingDefaults = await getBrandingDefaults(); - const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible; + // hero_logo_visible: store NULL ("inherit") unless the admin explicitly + // set it, so the global branding_logo_display_hero toggle keeps + // controlling this gallery afterwards (#756). Only an explicit per-event + // choice overrides the global. + const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined + ? formatBoolean(hero_logo_visible) + : null; const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; @@ -425,7 +431,8 @@ module.exports = (router) => { allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'), require_password: formatBoolean(requirePassword), css_template_id: css_template_id || null, - hero_logo_visible: formatBoolean(effectiveHeroLogoVisible), + // Already formatBoolean-coerced above, or null = inherit global (#756). + hero_logo_visible: effectiveHeroLogoVisible, hero_logo_size: effectiveHeroLogoSize, hero_logo_position: effectiveHeroLogoPosition, header_style: effectiveHeaderStyle || 'standard', @@ -1424,9 +1431,13 @@ module.exports = (router) => { updates.expires_at = null; } - // Format hero logo settings if provided + // Format hero logo settings if provided. null = inherit the global + // branding_logo_display_hero toggle (#756); only an explicit true/false + // is a per-event override. if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { - updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); + updates.hero_logo_visible = updates.hero_logo_visible === null + ? null + : formatBoolean(updates.hero_logo_visible); } // Per-event opt-in for hero-photo OG share image (#474). Coerce so diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index e24603a8..54ce3f2b 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -2,9 +2,21 @@ const express = require('express'); const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { getAppSetting } = require('../utils/appSettings'); const archiver = require('archiver'); const path = require('path'); const router = express.Router(); + +// #756: a NULL per-event hero_logo_visible means "inherit the global +// branding_logo_display_hero toggle". Only an explicit true/false is a +// per-gallery override. `globalDefault` is branding_logo_display_hero +// (defaults true when unset). +function resolveHeroLogoVisible(perEvent, globalDefault) { + if (perEvent === null || perEvent === undefined) { + return globalDefault !== false; + } + return perEvent !== false && perEvent !== 0 && perEvent !== '0'; +} const watermarkService = require('../services/watermarkService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService'); const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery'); @@ -182,6 +194,7 @@ router.get('/:slug/info', async (req, res) => { } const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true); res.json({ event_name: event.event_name, @@ -199,7 +212,7 @@ router.get('/:slug/info', async (req, res) => { watermark_text: event.watermark_text, enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1', use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', - hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0', + hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible), hero_logo_size: event.hero_logo_size || 'medium', hero_logo_position: event.hero_logo_position || 'top', hero_logo_url: event.hero_logo_url || null, @@ -635,7 +648,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) // selection back to source files. Tied to the same toggle as downloads — // one switch controls both surfaces. const useOriginalFilenames = await getUseOriginalFilenames(); - + const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true); res.json({ event: { @@ -654,7 +667,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) watermark_text: req.event.watermark_text, enable_devtools_protection: req.event.enable_devtools_protection === true, use_canvas_rendering: req.event.use_canvas_rendering === true, - hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0', + hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible), hero_logo_size: req.event.hero_logo_size || 'medium', hero_logo_position: req.event.hero_logo_position || 'top', hero_logo_url: req.event.hero_logo_url || null, diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index 377ddfcb..c9e8ba26 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -25,6 +25,10 @@ interface GalleryLayoutProps { promo_mode?: 'inherit' | 'custom' | 'off'; promo_markdown?: string | null; }; + // Effective hero-logo visibility for THIS gallery, already resolved by the + // backend (per-event override, else the global branding toggle) (#756). + // When provided it wins over brandingSettings.logo_display_hero. + heroLogoVisible?: boolean; brandingSettings?: { company_name?: string; company_tagline?: string; @@ -108,6 +112,7 @@ const HeaderDownloadButton: React.FC<{ export const GalleryLayout: React.FC = ({ event, brandingSettings, + heroLogoVisible, showLogout = false, onLogout, showDownloadAll = false, @@ -202,6 +207,9 @@ export const GalleryLayout: React.FC = ({ if (context === 'header') { return brandingSettings?.logo_display_header !== false; } else { + // #756: prefer the backend-resolved per-event value (override, else + // global); fall back to the global branding toggle if not provided. + if (heroLogoVisible !== undefined) return heroLogoVisible; return brandingSettings?.logo_display_hero !== false; } }; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 7ff8d5be..8d7201a8 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -820,6 +820,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown, }} brandingSettings={brandingSettings} + heroLogoVisible={data?.event?.hero_logo_visible !== false} headerStyle={data?.event?.header_style || theme.headerStyle} showLogout={true} onLogout={logout} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 871607d3..971feefe 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1110,6 +1110,9 @@ "protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering", "heroLogoSettings": "Hero-Logo-Einstellungen", "heroLogoVisible": "Logo im Hero-Bereich anzeigen", + "heroLogoInherit": "Branding-Standard verwenden", + "heroLogoShow": "Immer anzeigen", + "heroLogoHide": "Immer ausblenden", "heroLogoSize": "Logo-Größe", "heroLogoSizeSmall": "Klein", "heroLogoSizeMedium": "Mittel", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f6f85e94..93df80e8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -655,6 +655,9 @@ "protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering", "heroLogoSettings": "Hero Logo Settings", "heroLogoVisible": "Display logo in hero section", + "heroLogoInherit": "Use branding default", + "heroLogoShow": "Always show", + "heroLogoHide": "Always hide", "heroLogoSize": "Logo Size", "heroLogoSizeSmall": "Small", "heroLogoSizeMedium": "Medium", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 1d3a9363..84c852a1 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -306,8 +306,9 @@ export const EventDetailsPage: React.FC = () => { allow_presigned_download: (event as { allow_presigned_download?: boolean }).allow_presigned_download ?? false, enable_devtools_protection: event.enable_devtools_protection ?? true, use_canvas_rendering: event.use_canvas_rendering ?? false, - // Load hero logo settings from event - hero_logo_visible: event.hero_logo_visible ?? true, + // Load hero logo settings from event. Preserve null = "inherit global" + // (#756) — don't collapse it to true, or saving would snapshot an override. + hero_logo_visible: event.hero_logo_visible ?? null, hero_logo_size: event.hero_logo_size || 'medium', hero_logo_position: event.hero_logo_position || 'top', // Hero image anchor position (#162) diff --git a/frontend/src/pages/admin/event-details/EventInformationCard.tsx b/frontend/src/pages/admin/event-details/EventInformationCard.tsx index 51b25c00..3353f937 100644 --- a/frontend/src/pages/admin/event-details/EventInformationCard.tsx +++ b/frontend/src/pages/admin/event-details/EventInformationCard.tsx @@ -589,18 +589,31 @@ export const EventInformationCard: React.FC = ({
- +
+ + +
- {editForm.hero_logo_visible && ( + {editForm.hero_logo_visible !== false && ( <>