Merge pull request #757 from PicPeak/fix/hero-logo-global-inherit-756

fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
This commit is contained in:
Paul Nothaft
2026-07-06 11:50:06 +02:00
committed by GitHub
12 changed files with 216 additions and 42 deletions
@@ -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();
});
}
};
@@ -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();
});
}
};
+20 -7
View File
@@ -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
+21 -5
View File
@@ -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',
@@ -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<GalleryLayoutProps> = ({
event,
brandingSettings,
heroLogoVisible,
heroLogoSize,
showLogout = false,
onLogout,
showDownloadAll = false,
@@ -157,7 +168,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
// 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<GalleryLayoutProps> = ({
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<GalleryLayoutProps> = ({
};
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<GalleryLayoutProps> = ({
'/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))'
@@ -820,6 +820,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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}
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -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',
@@ -589,28 +589,43 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.hero_logo_visible}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
</label>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1 flex items-center gap-1">
<Image className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
{t('events.heroLogoVisible', 'Display logo in hero section')}
</label>
<select
// Tri-state (#756): "inherit" = null = follow the global
// Branding → "Show logo in hero" toggle; show/hide override it
// for just this gallery.
value={editForm.hero_logo_visible === null || editForm.hero_logo_visible === undefined
? 'inherit'
: editForm.hero_logo_visible ? 'show' : 'hide'}
onChange={(e) => setEditForm(prev => ({
...prev,
hero_logo_visible: e.target.value === 'inherit' ? null : e.target.value === 'show'
}))}
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
>
<option value="inherit">{t('events.heroLogoInherit', 'Use branding default')}</option>
<option value="show">{t('events.heroLogoShow', 'Always show')}</option>
<option value="hide">{t('events.heroLogoHide', 'Always hide')}</option>
</select>
</div>
{editForm.hero_logo_visible && (
{editForm.hero_logo_visible !== false && (
<>
<div className="ml-6">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.heroLogoSize', 'Logo Size')}
</label>
<select
value={editForm.hero_logo_size}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
// '' = inherit the global branding logo size (#756).
value={editForm.hero_logo_size ?? ''}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value === '' ? null : e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
>
<option value="">{t('events.heroLogoInherit', 'Use branding default')}</option>
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
@@ -24,9 +24,9 @@ export type EditFormState = {
allow_presigned_download: boolean;
enable_devtools_protection: boolean;
use_canvas_rendering: boolean;
// Hero logo settings
hero_logo_visible: boolean;
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
// Hero logo settings. null = inherit the global branding toggle (#756).
hero_logo_visible: boolean | null;
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge' | null;
hero_logo_position: 'top' | 'center' | 'bottom';
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor: string;
@@ -72,9 +72,9 @@ export const INITIAL_EDIT_FORM: EditFormState = {
allow_presigned_download: false,
enable_devtools_protection: true,
use_canvas_rendering: false,
// Hero logo settings
hero_logo_visible: true,
hero_logo_size: 'medium',
// Hero logo settings — null = inherit global branding toggle (#756)
hero_logo_visible: null,
hero_logo_size: null,
hero_logo_position: 'top',
// Hero image anchor position (#162)
hero_image_anchor: 'center',
+4 -4
View File
@@ -43,8 +43,8 @@ export interface Event {
enable_devtools_protection?: boolean;
use_canvas_rendering?: boolean;
// Hero logo customization fields
hero_logo_visible?: boolean;
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
hero_logo_visible?: boolean | null;
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
hero_logo_position?: 'top' | 'center' | 'bottom';
hero_logo_url?: string | null;
// Per-event opt-in for using the hero photo as the social-share
@@ -188,8 +188,8 @@ export interface GalleryData {
fragmentation_level?: number;
overlay_protection?: boolean;
// Hero logo customization fields
hero_logo_visible?: boolean;
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
hero_logo_visible?: boolean | null;
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
hero_logo_position?: 'top' | 'center' | 'bottom';
hero_logo_url?: string | null;
// Header style settings (decoupled from layout)