feat(footer): hideable legal links + socials + promo banner (#441 + #440)

Combined footer overhaul:

- Per-CMS-page show_in_footer toggle (#441) — admins can hide
  Impressum / Datenschutz from the gallery footer when an external
  privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
  Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
  icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
  global default authored as markdown in branding settings, plus a
  three-way per-event override on the Edit Event form
  (inherit / custom / off). Backend nulls promo_markdown automatically
  when mode != 'custom' so stale text never persists.

Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.

i18n covers all six locales (en/de/nl/pt/ru/fr).

Targets the beta branch.
This commit is contained in:
Paul Nothaft
2026-05-10 21:46:20 +02:00
parent c52c1c8419
commit 3a731e7c95
25 changed files with 778 additions and 93 deletions
@@ -0,0 +1,68 @@
import React, { useMemo } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
interface MarkdownContentProps {
/** Raw markdown source. Empty/null → renders nothing. */
source?: string | null;
/** Wrapper className. Default lets the consumer style spacing. */
className?: string;
}
// Sanitization allowlist — kept tight on purpose. The promo slot is
// admin-authored content rendered to gallery visitors, so the surface
// has to be conservative. Markdown source means contributors don't
// hand-write HTML in the first place; this is defense-in-depth.
const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 'del', 's',
'a', 'ul', 'ol', 'li', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'code', 'pre',
];
const ALLOWED_ATTR = ['href', 'title', 'target', 'rel'];
// Marked configuration:
// gfm: true → autolinks, strikethrough, tables (we don't allow tables in ALLOWED_TAGS so they sanitize out cleanly)
// breaks: true → newline = <br> (matches what an admin types in a textarea)
// pedantic: false → modern interpretation of edge cases
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
/**
* Render admin-authored markdown safely. Used by the gallery footer
* promotional slot (#440) and any future admin-content surface that
* needs more than plain text but less than a full WYSIWYG editor.
*
* Pipeline: marked.parse → DOMPurify with the allowlist above. Returns
* null when the source is empty so callers can use it inline without
* a wrapper-when-empty problem.
*/
export const MarkdownContent: React.FC<MarkdownContentProps> = ({ source, className }) => {
const html = useMemo(() => {
const md = (source ?? '').trim();
if (!md) return '';
const raw = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
// Force external links to noopener/noreferrer so admin-set URLs
// can't tab-nap the gallery context.
ADD_ATTR: ['target', 'rel'],
});
}, [source]);
if (!html) return null;
return (
<div
className={className}
// Add target=_blank + rel safety to all <a> after sanitize. Doing
// this with a hook would be cleaner but DOMPurify lacks a generic
// "set attribute on tag" hook in this version, so post-process.
dangerouslySetInnerHTML={{ __html: html.replace(
/<a /g,
'<a target="_blank" rel="noopener noreferrer nofollow" '
) }}
/>
);
};
+1
View File
@@ -23,3 +23,4 @@ export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator';
export { MarkdownContent } from './MarkdownContent';
+148 -57
View File
@@ -1,11 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { Calendar, Clock, Download, LogOut, Facebook, Instagram, Twitter, Youtube, MessageCircle } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common';
import { Button, MarkdownContent } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
@@ -19,6 +19,11 @@ interface GalleryLayoutProps {
event_type?: string;
event_date?: string | null;
expires_at?: string | null;
// Per-event promotional override (#440). 'inherit' uses the global
// branding_promo_markdown; 'custom' renders promo_markdown below;
// 'off' hides the promo slot entirely for this event.
promo_mode?: 'inherit' | 'custom' | 'off';
promo_markdown?: string | null;
};
brandingSettings?: {
company_name?: string;
@@ -34,6 +39,14 @@ interface GalleryLayoutProps {
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
hide_powered_by?: boolean;
// Footer overhaul (#441 + #440). Empty strings hide each socials icon.
facebook_url?: string;
instagram_url?: string;
whatsapp_url?: string;
twitter_url?: string;
youtube_url?: string;
promo_markdown?: string;
promo_position?: 'above_footer' | 'below_footer';
};
showLogout?: boolean;
onLogout?: () => void;
@@ -188,7 +201,49 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const headerLogoSize = getLogoDimensions('header');
const heroLogoSize = getLogoDimensions('hero');
// Footer overhaul (#441 + #440). All five socials are independent;
// empty string = hide just that icon. Per-event promo override:
// - off: never render the promo slot for this event
// - custom: render event.promo_markdown (falls back to global if blank)
// - inherit (default): render branding_promo_markdown
const socialLinks: Array<{ key: string; href: string; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
{ key: 'facebook', href: brandingSettings?.facebook_url || '', label: 'Facebook', Icon: Facebook },
{ key: 'instagram', href: brandingSettings?.instagram_url || '', label: 'Instagram', Icon: Instagram },
{ key: 'whatsapp', href: brandingSettings?.whatsapp_url || '', label: 'WhatsApp', Icon: MessageCircle },
{ key: 'twitter', href: brandingSettings?.twitter_url || '', label: 'X / Twitter', Icon: Twitter },
{ key: 'youtube', href: brandingSettings?.youtube_url || '', label: 'YouTube', Icon: Youtube },
].filter(link => link.href.trim().length > 0);
const promoMode = event.promo_mode || 'inherit';
const promoMarkdown = (() => {
if (promoMode === 'off') return '';
if (promoMode === 'custom') {
const eventMd = (event.promo_markdown || '').trim();
return eventMd || (brandingSettings?.promo_markdown || '');
}
return brandingSettings?.promo_markdown || '';
})().trim();
const promoPosition: 'above_footer' | 'below_footer' = brandingSettings?.promo_position === 'below_footer' ? 'below_footer' : 'above_footer';
const promoSlot = promoMarkdown ? (
<div className="gallery-promo border-t border-surface bg-surface/50">
<div className="container py-4 sm:py-6">
<div className="max-w-3xl mx-auto text-sm text-theme">
<MarkdownContent source={promoMarkdown} className="prose-sm prose-a:text-accent" />
</div>
</div>
</div>
) : null;
// Legal links per #441: each CMS page has show_in_footer (default true).
// When BOTH are hidden we still render the surrounding row only if
// there's a guest "Forget me" button or socials to show.
const showImpressum = impressumPage?.show_in_footer !== false;
const showDatenschutz = datenschutzPage?.show_in_footer !== false;
const hasLegalLinks = showImpressum || showDatenschutz;
const hasFooterRow = hasLegalLinks || socialLinks.length > 0 || !!guestIdentity?.identity;
return (
<div className="gallery-page min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
{/* Dynamic Favicon */}
@@ -573,13 +628,17 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Main Content */}
<main className="container">{children}</main>
{/* Promotional banner (#440) — rendered above the footer when
branding_promo_position = 'above_footer' (the default). */}
{promoPosition === 'above_footer' && promoSlot}
{/* Footer */}
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-surface">
<div className="container text-center px-4">
{brandingSettings?.support_email && (
<p className="text-xs sm:text-sm text-muted-theme mb-2">
{t('gallery.needHelp')}{' '}
<a
<a
href={`mailto:${brandingSettings.support_email}`}
className="text-accent hover:opacity-80 break-all"
>
@@ -598,62 +657,94 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{brandingSettings.company_name} - {brandingSettings.company_tagline}
</p>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
{impressumPage?.use_external_url && impressumPage.external_url ? (
<a
href={impressumPage.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</a>
) : (
<Link
to="/impressum"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</Link>
)}
<span className="text-xs text-muted-theme">|</span>
{datenschutzPage?.use_external_url && datenschutzPage.external_url ? (
<a
href={datenschutzPage.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</a>
) : (
<Link
to="/datenschutz"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</Link>
)}
{guestIdentity?.identity && (
<>
<span className="text-xs text-muted-theme">|</span>
<button
type="button"
className="text-xs text-muted-theme hover:text-theme transition-colors"
onClick={async () => {
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
await guestIdentity.forget();
}
}}
{/* Socials row (#441) — only rendered when at least one URL is set. */}
{socialLinks.length > 0 && (
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap" aria-label={t('gallery.footer.socials', 'Social media')}>
{socialLinks.map(({ key, href, label, Icon }) => (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="text-muted-theme hover:text-accent transition-colors"
>
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
</button>
</>
)}
</div>
<Icon className="w-5 h-5" />
</a>
))}
</div>
)}
{/* Legal Links (#441) — each CMS page has show_in_footer.
Only renders the row if there's something to put in it. */}
{hasFooterRow && (
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
{showImpressum && (
impressumPage?.use_external_url && impressumPage.external_url ? (
<a
href={impressumPage.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</a>
) : (
<Link
to="/impressum"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</Link>
)
)}
{showImpressum && showDatenschutz && (
<span className="text-xs text-muted-theme">|</span>
)}
{showDatenschutz && (
datenschutzPage?.use_external_url && datenschutzPage.external_url ? (
<a
href={datenschutzPage.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</a>
) : (
<Link
to="/datenschutz"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</Link>
)
)}
{guestIdentity?.identity && (
<>
{hasLegalLinks && <span className="text-xs text-muted-theme">|</span>}
<button
type="button"
className="text-xs text-muted-theme hover:text-theme transition-colors"
onClick={async () => {
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
await guestIdentity.forget();
}
}}
>
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
</button>
</>
)}
</div>
)}
</div>
</footer>
{/* Promotional banner (#440) — rendered below the footer when
branding_promo_position = 'below_footer'. */}
{promoPosition === 'below_footer' && promoSlot}
</div>
);
};
@@ -243,6 +243,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
logo_display_hero: settingsData.branding_logo_display_hero !== false,
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
hide_powered_by: settingsData.branding_hide_powered_by === true,
// Footer overhaul (#441 + #440). All five socials are optional;
// empty strings → that icon is hidden. promo_markdown is the
// global default; per-event override happens in GalleryLayout.
facebook_url: settingsData.branding_facebook_url || '',
instagram_url: settingsData.branding_instagram_url || '',
whatsapp_url: settingsData.branding_whatsapp_url || '',
twitter_url: settingsData.branding_twitter_url || '',
youtube_url: settingsData.branding_youtube_url || '',
promo_markdown: settingsData.branding_promo_markdown || '',
promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
});
}
}, [settingsData]);
@@ -726,7 +736,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
) : null}
<GalleryLayout
event={event}
event={{
...event,
// Per-event promo override (#440). Sourced from the gallery
// /info response so the layout can decide between inherit /
// custom / off without a second fetch.
promo_mode: (data?.event as { promo_mode?: 'inherit' | 'custom' | 'off' })?.promo_mode,
promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown,
}}
brandingSettings={brandingSettings}
headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={true}