From a803491cf477c0d62e23d6a019e0d252e2c537f8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 14 May 2026 20:10:14 +0200 Subject: [PATCH] fix(promo-banner): center by default + admin alignment selector (#482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gallery promotional banner (#440) read as visually offset from the gallery footer because: - Footer used `container text-center px-4` (full container width, centered text). - Promo block used `container py-4 sm:py-6` with an inner `max-w-3xl mx-auto` wrapper holding left-aligned text — a narrower column with left-aligned content sitting in the middle of the page. Two issues compounded: the column was narrower than the footer AND its text alignment differed. Reported by Rekoo-PS in #482 with a screenshot showing the misalignment, with a request for an admin alignment option. Fix: - Drop the inner max-w-3xl wrapper. Promo content now spans the same .container width as the footer, eliminating the narrower-column visual. - Default text alignment changed from left → center to match the footer. - New `branding_promo_alignment` setting ('left' | 'center' | 'right', default 'center'). Surfaced as a dropdown next to the existing Position dropdown on the BrandingPage. Live preview block on the BrandingPage mirrors the gallery render so admins see what guests will see. - Also replaced the no-op `prose-sm` prose-modifier with a real `prose prose-sm` outer class so the existing `prose-a:text-accent` modifier actually takes effect (it didn't before — modifiers without an outer .prose are silently ignored by Tailwind Typography). Migration 103 seeds the new setting at 'center' so existing installs that have a promo banner today see the corrected alignment immediately on next deploy. i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and flagged for native review per project convention. --- .../core/103_add_promo_alignment_setting.js | 43 +++++++++++++++ backend/src/routes/adminSettings.js | 15 +++++- backend/src/routes/publicSettings.js | 6 +++ .../src/components/gallery/GalleryLayout.tsx | 35 +++++++++++-- .../src/components/gallery/GalleryView.tsx | 5 ++ frontend/src/i18n/locales/de.json | 6 +++ frontend/src/i18n/locales/en.json | 6 +++ frontend/src/i18n/locales/fr.json | 6 +++ frontend/src/i18n/locales/nl.json | 6 +++ frontend/src/i18n/locales/pt.json | 6 +++ frontend/src/i18n/locales/ru.json | 6 +++ frontend/src/pages/admin/BrandingPage.tsx | 52 ++++++++++++++----- .../src/services/publicSettings.service.ts | 3 ++ frontend/src/services/settings.service.ts | 8 ++- 14 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 backend/migrations/core/103_add_promo_alignment_setting.js diff --git a/backend/migrations/core/103_add_promo_alignment_setting.js b/backend/migrations/core/103_add_promo_alignment_setting.js new file mode 100644 index 00000000..143341ff --- /dev/null +++ b/backend/migrations/core/103_add_promo_alignment_setting.js @@ -0,0 +1,43 @@ +/** + * Migration: Promotional banner text alignment (#482). + * + * Adds `branding_promo_alignment` to app_settings — admin-controlled + * horizontal alignment for the gallery promotional banner content + * (#440 / #482). Reuses the existing app_settings shape that + * branding_promo_markdown / branding_promo_position already use. + * + * Default 'center' so the banner aligns with the gallery footer + * (which is full-width center-aligned). The previous default left + * the markdown left-aligned in a max-w-3xl block, which Rekoo-PS + * reported as visually offset from the footer. + * + * Allowed values: 'left' | 'center' | 'right' — validated on the + * write path in adminSettings.js, not enforced by the column type + * (we use varchar instead of CHECK so the value can be extended + * later — e.g. 'justify' — without another schema migration). + * + * Idempotent: skips the insert when the row already exists. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('app_settings'))) return; + + const existing = await knex('app_settings') + .where('setting_key', 'branding_promo_alignment') + .first(); + if (existing) return; + + await knex('app_settings').insert({ + setting_key: 'branding_promo_alignment', + setting_value: JSON.stringify('center'), + setting_type: 'branding', + updated_at: new Date(), + }); +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('app_settings'))) return; + await knex('app_settings') + .where('setting_key', 'branding_promo_alignment') + .del(); +}; diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 5a65cfb3..bc9dc5ec 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -322,7 +322,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re twitter_url, youtube_url, promo_markdown, - promo_position + promo_position, + promo_alignment } = req.body; // Normalize force_color_mode: only 'dark' | 'light' | null are valid. @@ -340,6 +341,15 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re ? 'below_footer' : 'above_footer'; + // Normalize promo_alignment: 'left' | 'center' | 'right'. Defaults + // to 'center' to match the gallery footer's full-width centering + // (#482 — the previous default left the markdown left-aligned in + // a max-w-3xl block, which read as visually offset from the footer). + const allowedPromoAlignments = ['left', 'center', 'right']; + const normalizedPromoAlignment = allowedPromoAlignments.includes(promo_alignment) + ? promo_alignment + : 'center'; + // Normalize login_logo_size to the same token set as logo_size. // Anything else falls back to 'medium' on the next render. const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge']; @@ -381,7 +391,8 @@ 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 : '' }), - ...(promo_position !== undefined && { promo_position: normalizedPromoPosition }) + ...(promo_position !== undefined && { promo_position: normalizedPromoPosition }), + ...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment }) }; // Handle favicon deletion if empty string or null is provided diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 3dce3721..b7e9166f 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -77,6 +77,12 @@ router.get('/', async (req, res) => { branding_promo_position: settingsObject.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer', + // Promo content alignment (#482). Defaults to center so the + // banner aligns with the gallery footer; admin can flip to + // left or right via Settings → Branding. + branding_promo_alignment: ['left', 'center', 'right'].includes(settingsObject.branding_promo_alignment) + ? settingsObject.branding_promo_alignment + : 'center', // Force a specific color mode site-wide. When set, the user toggle // is hidden and the value overrides per-theme/system preference. // Allowed values: 'dark' | 'light' | null (null = no force). diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index bdce9112..f0b853a3 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -47,6 +47,9 @@ interface GalleryLayoutProps { youtube_url?: string; promo_markdown?: string; promo_position?: 'above_footer' | 'below_footer'; + // Horizontal alignment for the promo content (#482). Defaults + // to 'center' so the banner aligns with the footer. + promo_alignment?: 'left' | 'center' | 'right'; }; showLogout?: boolean; onLogout?: () => void; @@ -226,12 +229,36 @@ export const GalleryLayout: React.FC = ({ })().trim(); const promoPosition: 'above_footer' | 'below_footer' = brandingSettings?.promo_position === 'below_footer' ? 'below_footer' : 'above_footer'; + // Promo alignment (#482). Defaults to 'center' to match the gallery + // footer's `text-center px-4` so the banner reads as part of the + // same composition as the footer beneath it. Admin can flip to + // 'left' or 'right' from Settings → Branding. + const promoAlignment: 'left' | 'center' | 'right' = + brandingSettings?.promo_alignment === 'left' ? 'left' + : brandingSettings?.promo_alignment === 'right' ? 'right' + : 'center'; + const promoTextAlignClass = + promoAlignment === 'left' ? 'text-left' + : promoAlignment === 'right' ? 'text-right' + : 'text-center'; + const promoSlot = promoMarkdown ? (
-
-
- -
+ {/* + * Inner block uses .container (matches the footer's container + * width) + the alignment class. We deliberately drop the + * previous max-w-3xl wrapper — it created a narrower column + * that read as visually offset from the full-width footer + * (#482, reported by Rekoo-PS). The `prose` class is needed + * for the prose-a:text-accent modifier to actually take effect + * (modifiers without an outer .prose are no-ops in Tailwind + * Typography). + */} +
+
) : null; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 1ab07fd6..22bc4c6f 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -253,6 +253,11 @@ export const GalleryView: React.FC = ({ slug, event }) => { youtube_url: settingsData.branding_youtube_url || '', promo_markdown: settingsData.branding_promo_markdown || '', promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer', + // Promo alignment (#482). Defaults to 'center' to match the + // gallery footer; see GalleryLayout. + promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment) + ? settingsData.branding_promo_alignment + : 'center', }); } }, [settingsData]); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e76ca0e9..378a3005 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1792,6 +1792,12 @@ "sizeMedium": "Mittel (Standard)", "sizeLarge": "Groß", "sizeXLarge": "Sehr groß" + }, + "promo": { + "alignment": "Ausrichtung", + "alignLeft": "Links", + "alignCenter": "Zentriert (Standard – wie der Footer)", + "alignRight": "Rechts" } }, "admin": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 78740416..2f6e41aa 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1462,6 +1462,12 @@ "sizeMedium": "Medium (default)", "sizeLarge": "Large", "sizeXLarge": "Extra large" + }, + "promo": { + "alignment": "Alignment", + "alignLeft": "Left", + "alignCenter": "Center (default — matches footer)", + "alignRight": "Right" } }, "admin": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b7c88417..ae7671a3 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1400,6 +1400,12 @@ "sizeMedium": "Moyenne (par défaut)", "sizeLarge": "Grande", "sizeXLarge": "Très grande" + }, + "promo": { + "alignment": "Alignement", + "alignLeft": "À gauche", + "alignCenter": "Centré (par défaut — identique au pied de page)", + "alignRight": "À droite" } }, "admin": { diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index a86866b3..d4088907 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -1462,6 +1462,12 @@ "sizeMedium": "Middel (standaard)", "sizeLarge": "Groot", "sizeXLarge": "Extra groot" + }, + "promo": { + "alignment": "Uitlijning", + "alignLeft": "Links", + "alignCenter": "Gecentreerd (standaard — komt overeen met footer)", + "alignRight": "Rechts" } }, "admin": { diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index b872e41f..f85dfab8 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -1479,6 +1479,12 @@ "sizeMedium": "Médio (padrão)", "sizeLarge": "Grande", "sizeXLarge": "Extra grande" + }, + "promo": { + "alignment": "Alinhamento", + "alignLeft": "Esquerda", + "alignCenter": "Centro (padrão — igual ao rodapé)", + "alignRight": "Direita" } }, "admin": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 5f12e38b..64daa8ce 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1496,6 +1496,12 @@ "sizeMedium": "Средний (по умолчанию)", "sizeLarge": "Большой", "sizeXLarge": "Очень большой" + }, + "promo": { + "alignment": "Выравнивание", + "alignLeft": "По левому краю", + "alignCenter": "По центру (по умолчанию — как в подвале)", + "alignRight": "По правому краю" } }, "admin": { diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index df0e98f9..1f001f4c 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -43,6 +43,7 @@ export const BrandingPage: React.FC = () => { youtube_url: '', promo_markdown: '', promo_position: 'above_footer', + promo_alignment: 'center', }); const [currentTheme, setCurrentTheme] = useState(theme); @@ -420,18 +421,36 @@ export const BrandingPage: React.FC = () => { {t('branding.promo.help', 'Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.')}

-
- - +
+
+ + +
+ {/* Horizontal alignment (#482). Defaults to center + so the banner aligns with the gallery footer. */} +
+ + +
)} diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index 0ee322cd..da7d8f4c 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -39,6 +39,9 @@ export interface PublicSettings { branding_youtube_url?: string; branding_promo_markdown?: string; branding_promo_position?: 'above_footer' | 'below_footer'; + // Per-install promo banner alignment (#482). Defaults to 'center' + // so the banner aligns with the gallery footer's centering. + branding_promo_alignment?: 'left' | 'center' | 'right'; theme_config: any; default_language: string; enable_analytics: boolean; diff --git a/frontend/src/services/settings.service.ts b/frontend/src/services/settings.service.ts index 857ef961..b4aa9fd9 100644 --- a/frontend/src/services/settings.service.ts +++ b/frontend/src/services/settings.service.ts @@ -42,6 +42,9 @@ export interface BrandingSettings { youtube_url?: string; promo_markdown?: string; promo_position?: 'above_footer' | 'below_footer'; + // Per-install promo banner alignment (#482). Defaults to center + // so the banner aligns with the gallery footer. + promo_alignment?: 'left' | 'center' | 'right'; } export interface ThemeSettings { @@ -336,7 +339,10 @@ export const settingsService = { twitter_url: rawSettings.branding_twitter_url || '', youtube_url: rawSettings.branding_youtube_url || '', promo_markdown: rawSettings.branding_promo_markdown || '', - promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer' + promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer', + promo_alignment: ['left', 'center', 'right'].includes(rawSettings.branding_promo_alignment) + ? rawSettings.branding_promo_alignment + : 'center' }; },