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}
|
||||
|
||||
@@ -689,7 +689,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Ihr Name und Ihre Auswahl werden aus dieser Galerie entfernt.",
|
||||
"forgetMe": "Vergiss mich ({{name}})"
|
||||
"forgetMe": "Vergiss mich ({{name}})",
|
||||
"socials": "Soziale Netzwerke"
|
||||
},
|
||||
"photosCount_one": "{{count}} Foto",
|
||||
"photosCount_other": "{{count}} Fotos",
|
||||
@@ -1000,7 +1001,16 @@
|
||||
"importExternal": "Aus externem Ordner importieren",
|
||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||
"importFromSelectedFolder": "Aus ausgewähltem Ordner importieren"
|
||||
"importFromSelectedFolder": "Aus ausgewähltem Ordner importieren",
|
||||
"promoBanner": {
|
||||
"title": "Werbebanner",
|
||||
"help": "Lege fest, wie diese Galerie das Werbebanner behandelt. „Übernehmen\" nutzt die globale Vorgabe; „Eigene\" überschreibt sie für dieses Event; „Aus\" blendet das Banner für dieses Event aus.",
|
||||
"mode_inherit": "Globale Vorgabe übernehmen",
|
||||
"mode_custom": "Eigene Inhalte für dieses Event",
|
||||
"mode_off": "Aus (für dieses Event ausblenden)",
|
||||
"placeholder": "Markdown-Inhalt (z. B. **Aktion:** [jetzt Termin buchen](https://example.com))",
|
||||
"preview": "Vorschau"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
|
||||
@@ -324,7 +324,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Your name and selections will be removed from this gallery.",
|
||||
"forgetMe": "Forget me ({{name}})"
|
||||
"forgetMe": "Forget me ({{name}})",
|
||||
"socials": "Social media"
|
||||
},
|
||||
"photosCount_one": "{{count}} photo",
|
||||
"photosCount_other": "{{count}} photos",
|
||||
@@ -639,7 +640,16 @@
|
||||
"importExternal": "Import from External Folder",
|
||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||
"selectExternalFolder": "Select external folder under /external-media",
|
||||
"importFromSelectedFolder": "Import from selected folder"
|
||||
"importFromSelectedFolder": "Import from selected folder",
|
||||
"promoBanner": {
|
||||
"title": "Promotional Banner",
|
||||
"help": "Choose how this gallery handles the promotional banner. \"Inherit\" uses your global default; \"Custom\" overrides it for this event; \"Off\" hides it entirely.",
|
||||
"mode_inherit": "Inherit global default",
|
||||
"mode_custom": "Custom override for this event",
|
||||
"mode_off": "Off (hide for this event)",
|
||||
"placeholder": "Markdown content (e.g. **Special offer:** [book your next session](https://example.com))",
|
||||
"preview": "Preview"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
|
||||
@@ -333,7 +333,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Votre nom et vos sélections seront supprimés de cette galerie.",
|
||||
"forgetMe": "M'oublier ({{name}})"
|
||||
"forgetMe": "M'oublier ({{name}})",
|
||||
"socials": "Réseaux sociaux"
|
||||
},
|
||||
"photosCount_many": "{{count}} photos",
|
||||
"photosCount_one": "{{count}} photo",
|
||||
@@ -653,6 +654,15 @@
|
||||
"generatedLabel": "Mot de passe de galerie généré automatiquement",
|
||||
"saveSecurelyNote": "Important : Sauvegardez ce mot de passe en lieu sûr. Il ne pourra pas être récupéré une fois cette fenêtre fermée.",
|
||||
"done": "Terminé"
|
||||
},
|
||||
"promoBanner": {
|
||||
"title": "Bannière promotionnelle",
|
||||
"help": "Choisissez comment cette galerie gère la bannière promotionnelle. « Hériter » utilise la valeur par défaut globale ; « Personnalisé » la remplace pour cet événement ; « Désactivé » masque la bannière.",
|
||||
"mode_inherit": "Hériter de la valeur par défaut",
|
||||
"mode_custom": "Personnalisé pour cet événement",
|
||||
"mode_off": "Désactivé (masquer pour cet événement)",
|
||||
"placeholder": "Contenu Markdown (ex. **Offre :** [réservez votre prochaine séance](https://example.com))",
|
||||
"preview": "Aperçu"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -328,7 +328,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Uw naam en selecties worden verwijderd uit deze galerij.",
|
||||
"forgetMe": "Vergeet mij ({{name}})"
|
||||
"forgetMe": "Vergeet mij ({{name}})",
|
||||
"socials": "Sociale media"
|
||||
},
|
||||
"photosCount_one": "{{count}} foto",
|
||||
"photosCount_other": "{{count}} foto's",
|
||||
@@ -639,7 +640,16 @@
|
||||
"importExternal": "Importeren vanuit externe map",
|
||||
"externalImportInfo": "Alle afbeeldingen uit de geselecteerde map worden geïmporteerd.",
|
||||
"selectExternalFolder": "Externe map selecteren onder /external-media",
|
||||
"importFromSelectedFolder": "Importeren vanuit geselecteerde map"
|
||||
"importFromSelectedFolder": "Importeren vanuit geselecteerde map",
|
||||
"promoBanner": {
|
||||
"title": "Promotiebanner",
|
||||
"help": "Kies hoe deze galerij de promotiebanner toont. \"Overnemen\" gebruikt de globale standaard; \"Aangepast\" overschrijft deze voor dit evenement; \"Uit\" verbergt de banner volledig.",
|
||||
"mode_inherit": "Globale standaard overnemen",
|
||||
"mode_custom": "Aangepast voor dit evenement",
|
||||
"mode_off": "Uit (verbergen voor dit evenement)",
|
||||
"placeholder": "Markdown-inhoud (bijv. **Aanbieding:** [boek je volgende sessie](https://example.com))",
|
||||
"preview": "Voorbeeld"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systeeminstellingen",
|
||||
|
||||
@@ -335,7 +335,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"forgetMeConfirm": "O seu nome e seleções serão removidos desta galeria.",
|
||||
"forgetMe": "Esquecer-me ({{name}})"
|
||||
"forgetMe": "Esquecer-me ({{name}})",
|
||||
"socials": "Redes sociais"
|
||||
},
|
||||
"photosCount_many": "{{count}} fotos",
|
||||
"photosCount_one": "{{count}} foto",
|
||||
@@ -655,7 +656,16 @@
|
||||
"importExternal": "Importar de pasta externa",
|
||||
"externalImportInfo": "Todas as imagens da pasta selecionada serão importadas.",
|
||||
"selectExternalFolder": "Selecionar pasta externa em /external-media",
|
||||
"importFromSelectedFolder": "Importar da pasta selecionada"
|
||||
"importFromSelectedFolder": "Importar da pasta selecionada",
|
||||
"promoBanner": {
|
||||
"title": "Banner Promocional",
|
||||
"help": "Escolha como esta galeria lida com o banner promocional. \"Herdar\" usa o padrão global; \"Personalizado\" substitui apenas neste evento; \"Desligado\" oculta o banner para este evento.",
|
||||
"mode_inherit": "Herdar padrão global",
|
||||
"mode_custom": "Personalizado para este evento",
|
||||
"mode_off": "Desligado (ocultar neste evento)",
|
||||
"placeholder": "Conteúdo em Markdown (ex.: **Oferta especial:** [agende sua próxima sessão](https://example.com))",
|
||||
"preview": "Pré-visualização"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações do Sistema",
|
||||
|
||||
@@ -342,7 +342,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.",
|
||||
"forgetMe": "Забыть меня ({{name}})"
|
||||
"forgetMe": "Забыть меня ({{name}})",
|
||||
"socials": "Социальные сети"
|
||||
},
|
||||
"photosCount_few": "{{count}} фото",
|
||||
"photosCount_many": "{{count}} фото",
|
||||
@@ -671,7 +672,16 @@
|
||||
"importExternal": "Импорт из внешней папки",
|
||||
"externalImportInfo": "Все изображения из выбранной папки будут импортированы.",
|
||||
"selectExternalFolder": "Выберите внешнюю папку в /external-media",
|
||||
"importFromSelectedFolder": "Импортировать из выбранной папки"
|
||||
"importFromSelectedFolder": "Импортировать из выбранной папки",
|
||||
"promoBanner": {
|
||||
"title": "Промо-баннер",
|
||||
"help": "Выберите, как эта галерея отображает промо-баннер. «Наследовать» использует глобальную настройку; «Пользовательский» переопределяет для этого события; «Выключено» скрывает баннер.",
|
||||
"mode_inherit": "Наследовать глобальную настройку",
|
||||
"mode_custom": "Пользовательский для этого события",
|
||||
"mode_off": "Выключено (скрыть для этого события)",
|
||||
"placeholder": "Содержимое в Markdown (например, **Спецпредложение:** [записаться](https://example.com))",
|
||||
"preview": "Предпросмотр"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Системные настройки",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Save, Eye, Palette, Upload } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
|
||||
import { Button, Card, Input, ErrorBoundary, Loading, MarkdownContent } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
|
||||
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -32,6 +32,13 @@ export const BrandingPage: React.FC = () => {
|
||||
logo_display_mode: 'logo_and_text',
|
||||
hide_powered_by: false,
|
||||
force_color_mode: null,
|
||||
facebook_url: '',
|
||||
instagram_url: '',
|
||||
whatsapp_url: '',
|
||||
twitter_url: '',
|
||||
youtube_url: '',
|
||||
promo_markdown: '',
|
||||
promo_position: 'above_footer',
|
||||
});
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||
@@ -349,6 +356,108 @@ export const BrandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social media links (#441) — appear as icons in the gallery
|
||||
footer above the legal-links row. Empty = hidden. */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('branding.socialMedia.title', 'Social Media')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('branding.socialMedia.help', 'Add URLs to render social-media icons in the gallery footer. Leave a field empty to hide that icon.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Facebook"
|
||||
type="url"
|
||||
value={brandingSettings.facebook_url || ''}
|
||||
onChange={(e) => handleBrandingChange('facebook_url', e.target.value)}
|
||||
placeholder="https://facebook.com/yourstudio"
|
||||
/>
|
||||
<Input
|
||||
label="Instagram"
|
||||
type="url"
|
||||
value={brandingSettings.instagram_url || ''}
|
||||
onChange={(e) => handleBrandingChange('instagram_url', e.target.value)}
|
||||
placeholder="https://instagram.com/yourstudio"
|
||||
/>
|
||||
<Input
|
||||
label="WhatsApp"
|
||||
type="text"
|
||||
value={brandingSettings.whatsapp_url || ''}
|
||||
onChange={(e) => handleBrandingChange('whatsapp_url', e.target.value)}
|
||||
placeholder="https://wa.me/491234567890 or +491234567890"
|
||||
helperText={t('branding.socialMedia.whatsappHelp', 'A wa.me URL or a phone number with country code (will be converted).')}
|
||||
/>
|
||||
<Input
|
||||
label="X / Twitter"
|
||||
type="url"
|
||||
value={brandingSettings.twitter_url || ''}
|
||||
onChange={(e) => handleBrandingChange('twitter_url', e.target.value)}
|
||||
placeholder="https://x.com/yourstudio"
|
||||
/>
|
||||
<Input
|
||||
label="YouTube"
|
||||
type="url"
|
||||
value={brandingSettings.youtube_url || ''}
|
||||
onChange={(e) => handleBrandingChange('youtube_url', e.target.value)}
|
||||
placeholder="https://youtube.com/@yourstudio"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Promotional banner (#440) — markdown content rendered above
|
||||
or below the gallery footer. Per-event override is set on
|
||||
the Edit Event form; this is the global default. */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('branding.promo.title', 'Gallery Promotional Banner')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('branding.promo.help', 'Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.promo.position', 'Position')}
|
||||
</label>
|
||||
<select
|
||||
value={brandingSettings.promo_position || 'above_footer'}
|
||||
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
|
||||
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
|
||||
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.promo.content', 'Content (markdown)')}
|
||||
</label>
|
||||
<textarea
|
||||
value={brandingSettings.promo_markdown || ''}
|
||||
onChange={(e) => handleBrandingChange('promo_markdown', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 font-mono text-sm"
|
||||
rows={5}
|
||||
placeholder={t('branding.promo.placeholder', '**Spring offer**: 20% off prints with code SPRING — see the [print shop](https://example.com).')}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.promo.markdownHelp', 'Bold, italic, links, lists, and headings supported. HTML is stripped.')}
|
||||
</p>
|
||||
</div>
|
||||
{brandingSettings.promo_markdown && brandingSettings.promo_markdown.trim() && (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<div className="text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('branding.promo.preview', 'Preview')}
|
||||
</div>
|
||||
<MarkdownContent
|
||||
source={brandingSettings.promo_markdown}
|
||||
className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
|
||||
@@ -667,6 +667,28 @@ export const CMSPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer visibility (#441). Lets admins hide a CMS page
|
||||
from the gallery footer when their jurisdiction
|
||||
doesn't require it. Defaults to true on existing rows. */}
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={editForm.show_in_footer !== false}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, show_in_footer: e.target.checked }))}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('cms.showInFooter', 'Show in gallery footer')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('cms.showInFooterHelp', 'When off, this page is hidden from the public gallery footer. The page itself remains accessible at its direct URL.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
|
||||
@@ -57,7 +57,7 @@ const safeParseDate = (dateValue: unknown): Date | null => {
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
@@ -284,6 +284,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
photo_cap: number;
|
||||
// Default photo sort
|
||||
default_photo_sort: string;
|
||||
// Per-event promotional override (#440). Three-way mode:
|
||||
// inherit → use the global branding_promo_markdown
|
||||
// custom → render this event's promo_markdown
|
||||
// off → no promo for this event regardless of global
|
||||
promo_mode: 'inherit' | 'custom' | 'off';
|
||||
promo_markdown: string;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -321,6 +327,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
photo_cap: 0,
|
||||
// Default photo sort
|
||||
default_photo_sort: 'upload_date_desc',
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: 'inherit',
|
||||
promo_markdown: '',
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -576,6 +585,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
photo_cap: event.photo_cap || 0,
|
||||
// Default photo sort
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: ((event as { promo_mode?: 'inherit' | 'custom' | 'off' }).promo_mode) || 'inherit',
|
||||
promo_markdown: (event as { promo_markdown?: string }).promo_markdown || '',
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
@@ -718,6 +730,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// Header style settings (decoupled from layout, #158)
|
||||
header_style: currentTheme?.headerStyle || 'standard',
|
||||
hero_divider_style: currentTheme?.heroDividerStyle || 'wave',
|
||||
// Per-event promotional override (#440). Backend nulls
|
||||
// promo_markdown automatically when mode != 'custom'.
|
||||
promo_mode: editForm.promo_mode,
|
||||
promo_markdown: editForm.promo_mode === 'custom' ? editForm.promo_markdown : null,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -1365,6 +1381,52 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Promotional Banner Override (#440) — three-way: inherit / custom / off */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('events.promoBanner.title', 'Promotional Banner')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('events.promoBanner.help', 'Choose how this gallery handles the promotional banner. "Inherit" uses your global default; "Custom" overrides it for this event; "Off" hides it entirely.')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(['inherit', 'custom', 'off'] as const).map((mode) => (
|
||||
<label key={mode} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="promo_mode"
|
||||
value={mode}
|
||||
checked={editForm.promo_mode === mode}
|
||||
onChange={() => setEditForm(prev => ({ ...prev, promo_mode: mode }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t(`events.promoBanner.mode_${mode}`, mode === 'inherit' ? 'Inherit global default' : mode === 'custom' ? 'Custom override for this event' : 'Off (hide for this event)')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{editForm.promo_mode === 'custom' && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea
|
||||
value={editForm.promo_markdown}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, promo_markdown: e.target.value }))}
|
||||
rows={5}
|
||||
placeholder={t('events.promoBanner.placeholder', 'Markdown content (e.g. **Special offer:** [book your next session](https://example.com))')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark font-mono text-sm"
|
||||
/>
|
||||
{editForm.promo_markdown.trim() && (
|
||||
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3 bg-neutral-50 dark:bg-neutral-900">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('events.promoBanner.preview', 'Preview')}
|
||||
</p>
|
||||
<MarkdownContent source={editForm.promo_markdown} className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Protection Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface CMSPage {
|
||||
logo_url: string | null;
|
||||
use_external_url: boolean;
|
||||
external_url: string | null;
|
||||
// Footer visibility (#441). True = link rendered in the gallery footer.
|
||||
show_in_footer?: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +22,7 @@ export interface PublicCMSPage {
|
||||
logo_url: string | null;
|
||||
use_external_url: boolean;
|
||||
external_url: string | null;
|
||||
show_in_footer?: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ export interface PublicSettings {
|
||||
* AdminDarkModeContext + ThemeContext both honor this.
|
||||
*/
|
||||
branding_force_color_mode?: 'dark' | 'light' | null;
|
||||
// Footer overhaul (#441 + #440). Empty strings mean "hide".
|
||||
branding_facebook_url?: string;
|
||||
branding_instagram_url?: string;
|
||||
branding_whatsapp_url?: string;
|
||||
branding_twitter_url?: string;
|
||||
branding_youtube_url?: string;
|
||||
branding_promo_markdown?: string;
|
||||
branding_promo_position?: 'above_footer' | 'below_footer';
|
||||
theme_config: any;
|
||||
default_language: string;
|
||||
enable_analytics: boolean;
|
||||
|
||||
@@ -25,6 +25,16 @@ export interface BrandingSettings {
|
||||
* `colorMode` override is ignored. `null` means no force (default behavior).
|
||||
*/
|
||||
force_color_mode?: 'dark' | 'light' | null;
|
||||
// Footer overhaul (#441 + #440). Empty strings hide each social
|
||||
// icon individually; promo_markdown empty hides the slot for events
|
||||
// in 'inherit' mode. Position controls global default placement.
|
||||
facebook_url?: string;
|
||||
instagram_url?: string;
|
||||
whatsapp_url?: string;
|
||||
twitter_url?: string;
|
||||
youtube_url?: string;
|
||||
promo_markdown?: string;
|
||||
promo_position?: 'above_footer' | 'below_footer';
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
|
||||
Reference in New Issue
Block a user