feat(gallery): info banner above the photo grid (#932) (#1063)

* feat(gallery): info banner above the photo grid (#932)

A short informational note rendered at the TOP of a gallery, above the
photos. Distinct from the promotional banner (#440), which stays by the
footer for marketing copy — the reporter's case is an onboarding hint ("use
the menu button to filter"), which is useless below a gallery the guest has
to scroll past first.

Mirrors the promo feature's shape rather than inventing a second one: a
global default in Settings → Branding (branding_info_markdown) plus a
per-event inherit/custom/off override. Markdown via the existing
MarkdownContent sanitiser — no raw HTML, no CSS injection. Empty global
default means nothing renders, so upgrading changes nothing visible.

Deliberately NOT included: an alignment knob (this is short helper copy, not
marketing layout) and guest dismissal — the issue lists dismissal as a
nice-to-have, and it needs per-guest persistence that is its own decision.

Migration 176 is idempotent (hasColumn / existing-key guarded).

Note on the payload plumbing: the per-event fields travel in the /photos
response, not just /info. GalleryAuthContext seeds its cached event from the
gallery LOGIN response — a small identity subset — so anything absent there
is undefined right after a guest signs in. /photos is the payload that
refreshes on every gallery load, which is why the fields were added there
and why GalleryView reads them from `data.event`. Verified in a browser
across all three modes; reading them from the context event instead silently
collapsed every override back to 'inherit'.

* fix(branding): map branding_info_markdown on read so saving can't wipe it (#932)

External review caught this. BrandingSettings declared no info_markdown and
formatBrandingSettings never mapped branding_info_markdown, so BrandingPage's
hydration — setBrandingSettings(prev => ({ ...prev, ...formatted })) — kept
the empty-string initializer instead of the persisted value. The form loaded
blank and the next Save posted '' back, wiping a configured banner. Silently:
the gallery keeps rendering the old copy until that save lands.

This is the same bug the footer/promo fields hit in #441 + #440 / #460, which
the read mapper still carries a comment about. Add the field to the interface
and the mapper, and pin the round-trip for the whole editable branding set so
the next field added is caught by a test rather than by a user losing copy.

Verified: the new test fails 3/4 with the mapper line removed.

* fix(gallery): honour the info-banner override in the reveal-hidden view (#932)

External review, round 2. The hidden-until-reveal branch renders GalleryLayout
with the context `event`, which is seeded from the gallery login response and
carries no banner fields — so while a gallery was hidden, a per-event 'off'
silently resolved to 'inherit' and the global banner appeared on a gallery the
admin had muted.

Resolve the fields there the same way the main render path does. The two
full-page layouts (gallery-premium, gallery-story) are deliberately left alone:
they return before GalleryLayout and render no header, footer or promo banner
either — injecting a wrapper into layouts documented as having 'their own
integrated UI' would be a design change, not a fix.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-16 21:33:13 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent ab6ca6f82f
commit b48fa62eea
17 changed files with 575 additions and 7 deletions
@@ -0,0 +1,78 @@
/**
* Migration: gallery info banner (#932)
*
* A short informational message rendered ABOVE the photo grid, so guests see
* it on load. Distinct from the promotional banner (#440), which sits by the
* footer and stays there for marketing/CTA copy — the reporter's case is an
* onboarding hint ("use the menu button to filter"), which is useless below a
* gallery the guest has to scroll past first.
*
* Deliberately mirrors the promo columns rather than inventing a second
* shape: a global default in branding settings plus a per-event
* inherit/custom/off override. Same semantics, same normalisation, so the
* two banners stay predictable next to each other.
*
* Idempotent (hasColumn / existing-key guarded) so a re-run or a
* partially-applied state is safe.
*/
exports.up = async function (knex) {
// 1. events.info_mode — 'inherit' (use the global default) | 'custom'
// (this event's own copy) | 'off' (no banner for this gallery).
const hasInfoMode = await knex.schema.hasColumn('events', 'info_mode');
if (!hasInfoMode) {
await knex.schema.alterTable('events', (table) => {
table.string('info_mode', 16).notNullable().defaultTo('inherit');
});
console.log(' added events.info_mode (default "inherit")');
} else {
console.log(' events.info_mode already exists, skipping');
}
// 2. events.info_markdown — per-event copy, only read when mode = custom.
const hasInfoMarkdown = await knex.schema.hasColumn('events', 'info_markdown');
if (!hasInfoMarkdown) {
await knex.schema.alterTable('events', (table) => {
table.text('info_markdown').nullable();
});
console.log(' added events.info_markdown (nullable text)');
} else {
console.log(' events.info_markdown already exists, skipping');
}
// 3. The global default, following the existing `branding_<name>`
// convention used by the promo rows this mirrors. Empty string = the
// banner is off everywhere until an admin fills it in, so upgrading
// changes nothing visible for existing installs.
const infoSetting = {
setting_key: 'branding_info_markdown',
setting_value: JSON.stringify(''),
setting_type: 'branding',
};
const exists = await knex('app_settings').where('setting_key', infoSetting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...infoSetting, updated_at: knex.fn.now() });
console.log(' added branding_info_markdown (empty = banner off)');
} else {
console.log(' branding_info_markdown already exists, skipping');
}
console.log('Migration 176_gallery_info_banner completed');
};
exports.down = async function (knex) {
console.log('Rollback: 176_gallery_info_banner');
if (await knex.schema.hasTable('events')) {
if (await knex.schema.hasColumn('events', 'info_markdown')) {
await knex.schema.alterTable('events', (table) => table.dropColumn('info_markdown'));
}
if (await knex.schema.hasColumn('events', 'info_mode')) {
await knex.schema.alterTable('events', (table) => table.dropColumn('info_mode'));
}
}
if (await knex.schema.hasTable('app_settings')) {
await knex('app_settings').where('setting_key', 'branding_info_markdown').del();
}
};