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,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_<name>`
|
||||
// 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();
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Generated
+47
-15
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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