diff --git a/backend/migrations/core/089_footer_overhaul.js b/backend/migrations/core/089_footer_overhaul.js new file mode 100644 index 00000000..d3e6b17c --- /dev/null +++ b/backend/migrations/core/089_footer_overhaul.js @@ -0,0 +1,119 @@ +/** + * Migration 089: Footer overhaul (#441 + #440). + * + * Three concerns, all in the gallery footer area: + * + * 1. Per-CMS-page "Show in footer" toggle (#441 part a). Lets admins + * hide legal-link entries (Impressum, Datenschutz, etc.) when the + * target jurisdiction doesn't require them. Default TRUE so existing + * installs see no change. + * + * 2. Social links in the footer (#441 part b). Five branding settings + * for the canonical photographer-relevant networks: Facebook, + * Instagram, WhatsApp, X/Twitter, YouTube. All optional strings. + * + * 3. Promotional markdown slot above/below the footer (#440). Global + * default + per-event override with a three-way mode switch: + * - inherit (default): use the global, render nothing if global empty + * - custom: render the per-event markdown + * - off: suppress entirely for this event regardless of global + * + * Markdown only (no raw HTML) — the rendering pipeline is + * `marked → DOMPurify` on the frontend so admins can format text + * without opening an XSS surface. + */ + +exports.up = async function(knex) { + console.log('Running migration: 089_footer_overhaul'); + + // 1. cms_pages.show_in_footer + const hasShowInFooter = await knex.schema.hasColumn('cms_pages', 'show_in_footer'); + if (!hasShowInFooter) { + await knex.schema.alterTable('cms_pages', (table) => { + table.boolean('show_in_footer').notNullable().defaultTo(true); + }); + console.log(' added cms_pages.show_in_footer (default true)'); + } else { + console.log(' cms_pages.show_in_footer already exists, skipping'); + } + + // 2. events.promo_mode + events.promo_markdown + const hasPromoMode = await knex.schema.hasColumn('events', 'promo_mode'); + if (!hasPromoMode) { + await knex.schema.alterTable('events', (table) => { + table.string('promo_mode', 16).notNullable().defaultTo('inherit'); + }); + console.log(' added events.promo_mode (default "inherit")'); + } else { + console.log(' events.promo_mode already exists, skipping'); + } + + const hasPromoMarkdown = await knex.schema.hasColumn('events', 'promo_markdown'); + if (!hasPromoMarkdown) { + await knex.schema.alterTable('events', (table) => { + table.text('promo_markdown').nullable(); + }); + console.log(' added events.promo_markdown (nullable text)'); + } else { + console.log(' events.promo_markdown already exists, skipping'); + } + + // 3. New branding settings rows. Use the same `branding_` + // convention as the existing 21 branding rows (verified via + // SELECT setting_key FROM app_settings WHERE setting_key LIKE 'branding_%'). + const newSettings = [ + { setting_key: 'branding_facebook_url', setting_value: JSON.stringify(''), setting_type: 'branding' }, + { setting_key: 'branding_instagram_url', setting_value: JSON.stringify(''), setting_type: 'branding' }, + { setting_key: 'branding_whatsapp_url', setting_value: JSON.stringify(''), setting_type: 'branding' }, + { setting_key: 'branding_twitter_url', setting_value: JSON.stringify(''), setting_type: 'branding' }, + { setting_key: 'branding_youtube_url', setting_value: JSON.stringify(''), setting_type: 'branding' }, + { setting_key: 'branding_promo_markdown', setting_value: JSON.stringify(''), setting_type: 'branding' }, + // 'above_footer' | 'below_footer' (string instead of enum so we can + // expand without a schema change later). + { setting_key: 'branding_promo_position', setting_value: JSON.stringify('above_footer'), setting_type: 'branding' }, + ]; + + for (const setting of newSettings) { + const exists = await knex('app_settings').where('setting_key', setting.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() }); + } + } + console.log(` ensured ${newSettings.length} branding rows`); + + console.log('Migration 089_footer_overhaul completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 089_footer_overhaul'); + + if (await knex.schema.hasColumn('cms_pages', 'show_in_footer')) { + await knex.schema.alterTable('cms_pages', (table) => { + table.dropColumn('show_in_footer'); + }); + } + + if (await knex.schema.hasColumn('events', 'promo_mode')) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('promo_mode'); + }); + } + + if (await knex.schema.hasColumn('events', 'promo_markdown')) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('promo_markdown'); + }); + } + + await knex('app_settings') + .whereIn('setting_key', [ + 'branding_facebook_url', + 'branding_instagram_url', + 'branding_whatsapp_url', + 'branding_twitter_url', + 'branding_youtube_url', + 'branding_promo_markdown', + 'branding_promo_position', + ]) + .del(); +}; diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index 3a5c609c..7af0c2f6 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -73,7 +73,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ body('content_de').optional().isString(), body('logo_url').optional({ nullable: true }).isString(), body('use_external_url').optional().isBoolean(), - body('external_url').optional({ nullable: true }).isString() + body('external_url').optional({ nullable: true }).isString(), + body('show_in_footer').optional().isBoolean() ], async (req, res) => { try { const errors = validationResult(req); @@ -127,6 +128,9 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ const trimmed = typeof external_url === 'string' ? external_url.trim() : ''; updateFields.external_url = trimmed || null; } + if (Object.prototype.hasOwnProperty.call(req.body, 'show_in_footer')) { + updateFields.show_in_footer = !!req.body.show_in_footer; + } await db('cms_pages').where('slug', slug).update(updateFields); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index ebd27507..c05ee595 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -393,7 +393,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [ 'upload_date_desc', 'upload_date_asc', 'capture_date_desc', 'capture_date_asc', 'filename_asc', 'filename_desc' - ]) + ]), + // Per-event promotional override (#440). Three-way mode: + // inherit → fall back to global branding_promo_markdown + // custom → render this event's promo_markdown verbatim + // off → suppress entirely for this event + body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), + body('promo_markdown').optional({ nullable: true }).isString() ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -1100,7 +1106,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne 'upload_date_desc', 'upload_date_asc', 'capture_date_desc', 'capture_date_asc', 'filename_asc', 'filename_desc' - ]) + ]), + // Per-event promotional override (#440). Three-way mode: + // inherit → fall back to global branding_promo_markdown + // custom → render this event's promo_markdown verbatim + // off → suppress entirely for this event + body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), + body('promo_markdown').optional({ nullable: true }).isString() ], async (req, res) => { try { const errors = validationResult(req); @@ -1251,6 +1263,21 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); } + // Per-event promotional override (#440). Normalize promo_markdown to + // NULL when mode is anything other than 'custom' so we don't carry + // stale text after the admin switches modes. Empty markdown also + // becomes NULL. + if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode') + || Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) { + const mode = updates.promo_mode; + if (mode && mode !== 'custom') { + updates.promo_markdown = null; + } else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) { + const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : ''; + updates.promo_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 diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 7b832a18..0d8d9493 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -220,7 +220,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_hero, logo_display_mode, hide_powered_by, - force_color_mode + force_color_mode, + // Footer overhaul (#441 + #440). Socials are URL strings (empty + // hides the icon). Promo content is markdown (rendered via + // marked → DOMPurify on the frontend, no raw HTML accepted). + facebook_url, + instagram_url, + whatsapp_url, + twitter_url, + youtube_url, + promo_markdown, + promo_position } = req.body; // Normalize force_color_mode: only 'dark' | 'light' | null are valid. @@ -233,6 +243,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re // Get current watermark settings hash for change detection const oldSettingsHash = await watermarkService.getSettingsHash(); + // Normalize promo_position: only 'above_footer' | 'below_footer' valid. + const normalizedPromoPosition = promo_position === 'below_footer' + ? 'below_footer' + : 'above_footer'; + const brandingSettings = { company_name, company_tagline, @@ -252,7 +267,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_hero, logo_display_mode, hide_powered_by, - force_color_mode: normalizedForceColorMode + force_color_mode: normalizedForceColorMode, + // Footer overhaul (#441 + #440). String fields normalize empty/ + // undefined → '' so the column is always a known type. Only persist + // when the request actually included the key (partial PUTs). + ...(facebook_url !== undefined && { facebook_url: String(facebook_url || '').trim() }), + ...(instagram_url !== undefined && { instagram_url: String(instagram_url || '').trim() }), + ...(whatsapp_url !== undefined && { whatsapp_url: String(whatsapp_url || '').trim() }), + ...(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 }) }; // Handle favicon deletion if empty string or null is provided diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 8ab62592..d08f4962 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -127,7 +127,12 @@ router.get('/:slug/info', async (req, res) => { 'hero_divider_style', 'hero_image_anchor', 'is_draft', - 'default_photo_sort' + 'default_photo_sort', + // Per-event promotional override (#440). Resolution into a + // ready-to-render markdown string happens below so the + // frontend doesn't have to know about modes. + 'promo_mode', + 'promo_markdown' ) .first(); @@ -187,7 +192,11 @@ router.get('/:slug/info', async (req, res) => { header_style: event.header_style || 'standard', hero_divider_style: event.hero_divider_style || 'wave', hero_image_anchor: event.hero_image_anchor || 'center', - default_photo_sort: event.default_photo_sort || 'upload_date_desc' + default_photo_sort: event.default_photo_sort || 'upload_date_desc', + // 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 }); } catch (error) { console.error('Error fetching gallery info:', error); diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js index cafdadf7..2215c096 100644 --- a/backend/src/routes/publicCMS.js +++ b/backend/src/routes/publicCMS.js @@ -33,6 +33,11 @@ router.get('/pages/:slug', async (req, res) => { // toggle can be flipped back on, but it shouldn't leak via the API). use_external_url: !!page.use_external_url, external_url: page.use_external_url && page.external_url ? page.external_url : null, + // Footer visibility (#441). Default true for legacy rows; admins + // can hide a CMS page from the gallery footer when their + // jurisdiction doesn't require it (e.g. impressum / datenschutz + // outside DE/AT). + show_in_footer: page.show_in_footer !== false, updated_at: page.updated_at }); } catch (error) { diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 4c6c4fa8..c468e2fe 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -65,6 +65,18 @@ router.get('/', async (req, res) => { branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, + // Footer overhaul (#441 + #440). Empty strings hide each social + // icon individually; promo_markdown empty hides the slot for + // events in 'inherit' mode. + branding_facebook_url: settingsObject.branding_facebook_url || '', + branding_instagram_url: settingsObject.branding_instagram_url || '', + branding_whatsapp_url: settingsObject.branding_whatsapp_url || '', + branding_twitter_url: settingsObject.branding_twitter_url || '', + branding_youtube_url: settingsObject.branding_youtube_url || '', + branding_promo_markdown: settingsObject.branding_promo_markdown || '', + branding_promo_position: settingsObject.branding_promo_position === 'below_footer' + ? 'below_footer' + : 'above_footer', // 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/package-lock.json b/frontend/package-lock.json index d991d8e7..ce58fe8c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", - "version": "3.42.7-beta.0", + "version": "3.44.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.42.7-beta.0", + "version": "3.44.1-beta.0", "dependencies": { "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", @@ -33,6 +33,7 @@ "lodash": "^4.17.21", "lowlight": "^2.9.0", "lucide-react": "0.525.0", + "marked": "^15.0.12", "photoswipe": "^5.4.4", "react": "^18.3.1", "react-countdown": "^2.3.5", @@ -149,6 +150,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -514,6 +516,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -537,6 +540,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2804,7 +2808,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2814,8 +2817,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2884,6 +2886,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2980,6 +2983,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz", "integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3258,6 +3262,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3344,8 +3349,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3476,6 +3480,7 @@ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -3491,6 +3496,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3502,6 +3508,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3579,6 +3586,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -3945,6 +3953,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3995,7 +4004,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4211,6 +4219,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4695,7 +4704,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -4885,6 +4893,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5684,6 +5693,7 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -5786,6 +5796,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5949,6 +5960,7 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6287,6 +6299,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6577,6 +6590,7 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", "license": "MIT", + "peer": true, "dependencies": { "@types/hast": "^2.0.0", "fault": "^2.0.0", @@ -6612,7 +6626,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6644,6 +6657,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7216,6 +7241,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7263,6 +7289,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7422,7 +7449,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7438,7 +7464,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7451,8 +7476,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -7593,6 +7617,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -7622,6 +7647,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -7670,6 +7696,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -7730,6 +7757,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7768,6 +7796,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8682,7 +8711,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -8703,6 +8733,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8824,6 +8855,7 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index f8cc62a2..08b915b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "lodash": "^4.17.21", "lowlight": "^2.9.0", "lucide-react": "0.525.0", + "marked": "^15.0.12", "photoswipe": "^5.4.4", "react": "^18.3.1", "react-countdown": "^2.3.5", diff --git a/frontend/src/components/common/MarkdownContent.tsx b/frontend/src/components/common/MarkdownContent.tsx new file mode 100644 index 00000000..91b43c0b --- /dev/null +++ b/frontend/src/components/common/MarkdownContent.tsx @@ -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 =
(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 = ({ 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 ( +
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( + / + ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 1ac81226..46702452 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -23,3 +23,4 @@ export { ProtectedImage } from './ProtectedImage'; export { ProtectionWarning } from './ProtectionWarning'; export { ReCaptcha } from './ReCaptcha'; export { PasswordGenerator } from './PasswordGenerator'; +export { MarkdownContent } from './MarkdownContent'; diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index 1346a234..bdce9112 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -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 = ({ 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 ? ( +
+
+
+ +
+
+
+ ) : 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 (
{/* Dynamic Favicon */} @@ -573,13 +628,17 @@ export const GalleryLayout: React.FC = ({ {/* Main Content */}
{children}
+ {/* Promotional banner (#440) — rendered above the footer when + branding_promo_position = 'above_footer' (the default). */} + {promoPosition === 'above_footer' && promoSlot} + {/* Footer */}
+ + {/* Promotional banner (#440) — rendered below the footer when + branding_promo_position = 'below_footer'. */} + {promoPosition === 'below_footer' && promoSlot}
); }; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 04f5a21a..1ab07fd6 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -243,6 +243,16 @@ export const GalleryView: React.FC = ({ 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 = ({ slug, event }) => { ) : null} { 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(theme); @@ -349,6 +356,108 @@ export const BrandingPage: React.FC = () => { + {/* Social media links (#441) — appear as icons in the gallery + footer above the legal-links row. Empty = hidden. */} +
+

+ {t('branding.socialMedia.title', 'Social Media')} +

+

+ {t('branding.socialMedia.help', 'Add URLs to render social-media icons in the gallery footer. Leave a field empty to hide that icon.')} +

+
+ handleBrandingChange('facebook_url', e.target.value)} + placeholder="https://facebook.com/yourstudio" + /> + handleBrandingChange('instagram_url', e.target.value)} + placeholder="https://instagram.com/yourstudio" + /> + 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).')} + /> + handleBrandingChange('twitter_url', e.target.value)} + placeholder="https://x.com/yourstudio" + /> + handleBrandingChange('youtube_url', e.target.value)} + placeholder="https://youtube.com/@yourstudio" + /> +
+
+ + {/* 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. */} +
+

+ {t('branding.promo.title', 'Gallery Promotional Banner')} +

+

+ {t('branding.promo.help', 'Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.')} +

+
+
+ + +
+
+ +