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:
@@ -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" '
|
||||
) }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -23,3 +23,4 @@ export { ProtectedImage } from './ProtectedImage';
|
||||
export { ProtectionWarning } from './ProtectionWarning';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
export { PasswordGenerator } from './PasswordGenerator';
|
||||
export { MarkdownContent } from './MarkdownContent';
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user