* 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:
co-authored by
Paul Nothaft
parent
ab6ca6f82f
commit
b48fa62eea
@@ -117,6 +117,10 @@ module.exports = (router) => {
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString(),
|
||||
// Per-event info-banner override (#932). Same three-way mode as promo,
|
||||
// but resolved against branding_info_markdown and rendered above the grid.
|
||||
body('info_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('info_markdown').optional({ nullable: true }).isString(),
|
||||
// Per-event opt-in for using hero photo as the social-share preview
|
||||
// image (#474). When false (default), galleryOgService falls back to
|
||||
// the brand logo for og:image / Twitter Card.
|
||||
@@ -1264,6 +1268,10 @@ module.exports = (router) => {
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString(),
|
||||
// Per-event info-banner override (#932). Same three-way mode as promo,
|
||||
// but resolved against branding_info_markdown and rendered above the grid.
|
||||
body('info_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('info_markdown').optional({ nullable: true }).isString(),
|
||||
// Per-event opt-in for using hero photo as the social-share preview
|
||||
// image (#474). When false (default), galleryOgService falls back to
|
||||
// the brand logo for og:image / Twitter Card.
|
||||
@@ -1540,6 +1548,20 @@ module.exports = (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event info-banner override (#932). Identical normalisation to
|
||||
// promo above — mode != custom drops the stored text so switching
|
||||
// modes can't leave stale copy behind.
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'info_mode')
|
||||
|| Object.prototype.hasOwnProperty.call(updates, 'info_markdown')) {
|
||||
const mode = updates.info_mode;
|
||||
if (mode && mode !== 'custom') {
|
||||
updates.info_markdown = null;
|
||||
} else if (Object.prototype.hasOwnProperty.call(updates, 'info_markdown')) {
|
||||
const md = typeof updates.info_markdown === 'string' ? updates.info_markdown.trim() : '';
|
||||
updates.info_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
|
||||
|
||||
@@ -927,7 +927,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
youtube_url,
|
||||
promo_markdown,
|
||||
promo_position,
|
||||
promo_alignment
|
||||
promo_alignment,
|
||||
// Info banner (#932). Markdown only, same sanitiser path as promo.
|
||||
info_markdown
|
||||
} = req.body;
|
||||
|
||||
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
|
||||
@@ -995,6 +997,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
...(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 : '' }),
|
||||
...(info_markdown !== undefined && { info_markdown: typeof info_markdown === 'string' ? info_markdown : '' }),
|
||||
...(promo_position !== undefined && { promo_position: normalizedPromoPosition }),
|
||||
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
|
||||
};
|
||||
|
||||
@@ -261,7 +261,9 @@ router.get('/:slug/info', async (req, res) => {
|
||||
// ready-to-render markdown string happens below so the
|
||||
// frontend doesn't have to know about modes.
|
||||
'promo_mode',
|
||||
'promo_markdown'
|
||||
'promo_markdown',
|
||||
'info_mode',
|
||||
'info_markdown'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -342,7 +344,11 @@ router.get('/:slug/info', async (req, res) => {
|
||||
// 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
|
||||
promo_markdown: event.promo_markdown || null,
|
||||
// Info banner (#932). Same inherit/custom/off semantics as promo,
|
||||
// resolved against branding_info_markdown from public settings.
|
||||
info_mode: event.info_mode || 'inherit',
|
||||
info_markdown: event.info_markdown || null
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch gallery info');
|
||||
@@ -939,6 +945,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
hero_divider_style: req.event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
||||
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
|
||||
// Info banner override (#932). GalleryAuthContext refreshes its cached
|
||||
// event from THIS payload, so the fields have to travel here — /info
|
||||
// alone isn't enough, the context stops reading it once the guest is
|
||||
// authenticated.
|
||||
info_mode: req.event.info_mode || 'inherit',
|
||||
info_markdown: req.event.info_markdown || null,
|
||||
download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at),
|
||||
// Mirror of the admin-side toggle so the lightbox can decide
|
||||
// whether to surface original camera filenames (#508).
|
||||
|
||||
@@ -95,6 +95,10 @@ router.get('/', async (req, res) => {
|
||||
branding_twitter_url: settingsObject.branding_twitter_url || '',
|
||||
branding_youtube_url: settingsObject.branding_youtube_url || '',
|
||||
branding_promo_markdown: settingsObject.branding_promo_markdown || '',
|
||||
// Info banner (#932). Rendered ABOVE the photo grid, unlike the promo
|
||||
// banner by the footer — an onboarding hint is useless below a gallery
|
||||
// the guest has to scroll past. Empty = off everywhere.
|
||||
branding_info_markdown: settingsObject.branding_info_markdown || '',
|
||||
branding_promo_position: settingsObject.branding_promo_position === 'below_footer'
|
||||
? 'below_footer'
|
||||
: 'above_footer',
|
||||
|
||||
Reference in New Issue
Block a user