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/migrations/core/153_hero_logo_size_inherit.js b/backend/migrations/core/153_hero_logo_size_inherit.js new file mode 100644 index 00000000..cebbf8c2 --- /dev/null +++ b/backend/migrations/core/153_hero_logo_size_inherit.js @@ -0,0 +1,51 @@ +/** + * Migration 153: make events.hero_logo_size NULL-able so NULL means "inherit + * the global branding_logo_size" (#756 follow-up — the size counterpart of 152). + * + * Before: hero_logo_size was `varchar NOT NULL DEFAULT 'medium'`, snapshotted + * from the global branding_logo_size at creation. The two gallery render paths + * then disagreed — GalleryLayout read the global size live, while the + * hero-header path used the per-event snapshot — so a hero logo could render at + * different sizes on different layouts, and changing the global size didn't + * update hero-header galleries. + * + * After: NULL = inherit. gallery read-resolution falls back to + * branding_logo_size when the per-event value is NULL, and both render paths + * consume that resolved size. + * + * Data backfill: NULL out ALL existing hero_logo_size so every gallery inherits + * the global size going forward. Unlike a boolean we can't tell a defaulted + * value from a chosen one — but nulling is the safe choice here: it restores the + * live-global behaviour GalleryLayout already had, and the per-event size can be + * re-set from the event's edit page. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return; + + const client = (knex.client.config.client || '').toLowerCase(); + if (client === 'pg' || client === 'postgresql') { + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP DEFAULT'); + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP NOT NULL'); + } else { + await knex.schema.alterTable('events', (t) => { + t.string('hero_logo_size', 20).nullable().alter(); + }); + } + + await knex('events').update({ hero_logo_size: null }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return; + await knex('events').whereNull('hero_logo_size').update({ hero_logo_size: 'medium' }); + + const client = (knex.client.config.client || '').toLowerCase(); + if (client === 'pg' || client === 'postgresql') { + await knex.raw("ALTER TABLE events ALTER COLUMN hero_logo_size SET DEFAULT 'medium'"); + await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size SET NOT NULL'); + } else { + await knex.schema.alterTable('events', (t) => { + t.string('hero_logo_size', 20).notNullable().defaultTo('medium').alter(); + }); + } +}; diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 67e985a0..238c4456 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -95,7 +95,7 @@ module.exports = (router) => { body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), // Hero logo settings body('hero_logo_visible').optional().isBoolean(), - body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), // Header style settings (decoupled from layout) body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), @@ -339,8 +339,16 @@ 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; - const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; + // 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; + // NULL = inherit the global branding_logo_size (#756), resolved at read + // time. Only an explicit per-event size overrides it. + const effectiveHeroLogoSize = req.body.hero_logo_size || null; const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; // Inherit "Detect dev tools" from the global Image Security setting unless @@ -425,7 +433,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', @@ -1216,7 +1225,7 @@ module.exports = (router) => { body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), // Hero logo settings body('hero_logo_visible').optional().isBoolean(), - body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), // Header style settings (decoupled from layout) body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), @@ -1424,9 +1433,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..ba62bf1e 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,8 @@ 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); + const globalLogoSize = await getAppSetting('branding_logo_size', 'medium'); res.json({ event_name: event.event_name, @@ -199,8 +213,9 @@ 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_size: event.hero_logo_size || 'medium', + hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible), + // #756: NULL per-event size inherits the global branding_logo_size. + hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium', hero_logo_position: event.hero_logo_position || 'top', hero_logo_url: event.hero_logo_url || null, header_style: event.header_style || 'standard', @@ -635,7 +650,8 @@ 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); + const globalLogoSize = await getAppSetting('branding_logo_size', 'medium'); res.json({ event: { @@ -654,8 +670,8 @@ 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_size: req.event.hero_logo_size || 'medium', + hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible), + hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium', hero_logo_position: req.event.hero_logo_position || 'top', hero_logo_url: req.event.hero_logo_url || null, header_style: req.event.header_style || 'standard', diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index 377ddfcb..60d8e064 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -25,6 +25,15 @@ 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; + // Effective hero-logo SIZE, resolved the same way (per-event override, else + // the global branding_logo_size) (#756). When provided it wins over + // brandingSettings.logo_size for the hero logo — so both render paths + // (this layout and the hero-header) size the logo identically. + heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom'; brandingSettings?: { company_name?: string; company_tagline?: string; @@ -108,6 +117,8 @@ const HeaderDownloadButton: React.FC<{ export const GalleryLayout: React.FC = ({ event, brandingSettings, + heroLogoVisible, + heroLogoSize, showLogout = false, onLogout, showDownloadAll = false, @@ -157,7 +168,10 @@ export const GalleryLayout: React.FC = ({ // Calculate logo size classes based on settings const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => { - const size = brandingSettings?.logo_size || 'medium'; + // #756: the hero logo uses the backend-resolved per-event size (override, + // else global) so it matches the hero-header layout; the header logo keeps + // the global size. + const size = (context === 'hero' && heroLogoSize) ? heroLogoSize : (brandingSettings?.logo_size || 'medium'); const maxHeight = brandingSettings?.logo_max_height || 48; if (size === 'custom') { @@ -202,6 +216,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; } }; @@ -213,7 +230,7 @@ export const GalleryLayout: React.FC = ({ }; const headerLogoSize = getLogoDimensions('header'); - const heroLogoSize = getLogoDimensions('hero'); + const heroLogoDimensions = getLogoDimensions('hero'); // Footer overhaul (#441 + #440). All five socials are independent; // empty string = hide just that icon. Per-event promo override: @@ -599,9 +616,9 @@ export const GalleryLayout: React.FC = ({ '/picpeak-logo-transparent.png' } alt={brandingSettings?.company_name || 'PicPeak'} - className={`${heroLogoSize.className} w-auto object-contain mx-auto`} + className={`${heroLogoDimensions.className} w-auto object-contain mx-auto`} style={{ - ...(heroLogoSize.style || {}), + ...(heroLogoDimensions.style || {}), // Only apply brightness/invert filter to default logo; custom logos display as-is filter: brandLogoUrl ? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 7ff8d5be..b63eca31 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -820,6 +820,8 @@ 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} + heroLogoSize={data?.event?.hero_logo_size || undefined} 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..ac3fd15a 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -306,9 +306,11 @@ 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, - hero_logo_size: event.hero_logo_size || 'medium', + // 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, + // Preserve null = "inherit global size" (#756) — don't collapse to medium. + hero_logo_size: event.hero_logo_size ?? null, hero_logo_position: event.hero_logo_position || 'top', // Hero image anchor position (#162) hero_image_anchor: event.hero_image_anchor || 'center', diff --git a/frontend/src/pages/admin/event-details/EventInformationCard.tsx b/frontend/src/pages/admin/event-details/EventInformationCard.tsx index 51b25c00..24290e85 100644 --- a/frontend/src/pages/admin/event-details/EventInformationCard.tsx +++ b/frontend/src/pages/admin/event-details/EventInformationCard.tsx @@ -589,28 +589,43 @@ export const EventInformationCard: React.FC = ({
- +
+ + +
- {editForm.hero_logo_visible && ( + {editForm.hero_logo_visible !== false && ( <>