Merge pull request #450 from the-luap/feat/footer-overhaul-441-440

feat(footer): hideable legal links + socials + promo banner (#441 + #440)
This commit is contained in:
Paul Nothaft
2026-05-10 21:57:35 +02:00
committed by GitHub
25 changed files with 778 additions and 93 deletions
@@ -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();
};
+5 -1
View File
@@ -73,7 +73,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('content_de').optional().isString(), body('content_de').optional().isString(),
body('logo_url').optional({ nullable: true }).isString(), body('logo_url').optional({ nullable: true }).isString(),
body('use_external_url').optional().isBoolean(), 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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); 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() : ''; const trimmed = typeof external_url === 'string' ? external_url.trim() : '';
updateFields.external_url = trimmed || null; 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); await db('cms_pages').where('slug', slug).update(updateFields);
+29 -2
View File
@@ -393,7 +393,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
'upload_date_desc', 'upload_date_asc', 'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc', 'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc' '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) => { ], async (req, res) => {
try { try {
logger.debug('Create event request body', { body: req.body }); 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', 'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc', 'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc' '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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); 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); 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 // Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the // explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the // database columns stay in sync even if the frontend only sends the
+27 -2
View File
@@ -220,7 +220,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_hero, logo_display_hero,
logo_display_mode, logo_display_mode,
hide_powered_by, 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; } = req.body;
// Normalize force_color_mode: only 'dark' | 'light' | null are valid. // 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 // Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash(); 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 = { const brandingSettings = {
company_name, company_name,
company_tagline, company_tagline,
@@ -252,7 +267,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_hero, logo_display_hero,
logo_display_mode, logo_display_mode,
hide_powered_by, 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 // Handle favicon deletion if empty string or null is provided
+11 -2
View File
@@ -127,7 +127,12 @@ router.get('/:slug/info', async (req, res) => {
'hero_divider_style', 'hero_divider_style',
'hero_image_anchor', 'hero_image_anchor',
'is_draft', '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(); .first();
@@ -187,7 +192,11 @@ router.get('/:slug/info', async (req, res) => {
header_style: event.header_style || 'standard', header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave', hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center', 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) { } catch (error) {
console.error('Error fetching gallery info:', error); console.error('Error fetching gallery info:', error);
+5
View File
@@ -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). // toggle can be flipped back on, but it shouldn't leak via the API).
use_external_url: !!page.use_external_url, use_external_url: !!page.use_external_url,
external_url: page.use_external_url && page.external_url ? page.external_url : null, 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 updated_at: page.updated_at
}); });
} catch (error) { } catch (error) {
+12
View File
@@ -65,6 +65,18 @@ router.get('/', async (req, res) => {
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, 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 // Force a specific color mode site-wide. When set, the user toggle
// is hidden and the value overrides per-theme/system preference. // is hidden and the value overrides per-theme/system preference.
// Allowed values: 'dark' | 'light' | null (null = no force). // Allowed values: 'dark' | 'light' | null (null = no force).
+47 -15
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "3.42.7-beta.0", "version": "3.44.1-beta.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "3.42.7-beta.0", "version": "3.44.1-beta.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-character-count": "^2.26.1",
@@ -33,6 +33,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lowlight": "^2.9.0", "lowlight": "^2.9.0",
"lucide-react": "0.525.0", "lucide-react": "0.525.0",
"marked": "^15.0.12",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-countdown": "^2.3.5", "react-countdown": "^2.3.5",
@@ -149,6 +150,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -514,6 +516,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -537,6 +540,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2804,7 +2808,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -2814,8 +2817,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@testing-library/jest-dom": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
@@ -2884,6 +2886,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "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", "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz",
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==", "integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -3258,6 +3262,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.3.0", "prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
@@ -3344,8 +3349,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -3476,6 +3480,7 @@
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.19.0" "undici-types": "~7.19.0"
} }
@@ -3491,6 +3496,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -3502,6 +3508,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -3579,6 +3586,7 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0", "@typescript-eslint/types": "8.48.0",
@@ -3945,6 +3953,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3995,7 +4004,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -4211,6 +4219,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4695,7 +4704,6 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -4885,6 +4893,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5684,6 +5693,7 @@
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
} }
@@ -5786,6 +5796,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
@@ -5949,6 +5960,7 @@
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6287,6 +6299,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -6577,6 +6590,7 @@
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/hast": "^2.0.0", "@types/hast": "^2.0.0",
"fault": "^2.0.0", "fault": "^2.0.0",
@@ -6612,7 +6626,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -6644,6 +6657,18 @@
"markdown-it": "bin/markdown-it.mjs" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7216,6 +7241,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7263,6 +7289,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -7422,7 +7449,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -7438,7 +7464,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -7451,8 +7476,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/pretty-ms": { "node_modules/pretty-ms": {
"version": "9.3.0", "version": "9.3.0",
@@ -7593,6 +7617,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@@ -7622,6 +7647,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^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", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@@ -7730,6 +7757,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -7768,6 +7796,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -8682,7 +8711,8 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
@@ -8703,6 +8733,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -8824,6 +8855,7 @@
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
+1
View File
@@ -40,6 +40,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lowlight": "^2.9.0", "lowlight": "^2.9.0",
"lucide-react": "0.525.0", "lucide-react": "0.525.0",
"marked": "^15.0.12",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-countdown": "^2.3.5", "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" '
) }}
/>
);
};
+1
View File
@@ -23,3 +23,4 @@ export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning'; export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha'; export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator'; export { PasswordGenerator } from './PasswordGenerator';
export { MarkdownContent } from './MarkdownContent';
@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; 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 { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common'; import { Button, MarkdownContent } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon'; import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
@@ -19,6 +19,11 @@ interface GalleryLayoutProps {
event_type?: string; event_type?: string;
event_date?: string | null; event_date?: string | null;
expires_at?: 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?: { brandingSettings?: {
company_name?: string; company_name?: string;
@@ -34,6 +39,14 @@ interface GalleryLayoutProps {
logo_display_hero?: boolean; logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text'; logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
hide_powered_by?: boolean; 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; showLogout?: boolean;
onLogout?: () => void; onLogout?: () => void;
@@ -189,6 +202,48 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const headerLogoSize = getLogoDimensions('header'); const headerLogoSize = getLogoDimensions('header');
const heroLogoSize = getLogoDimensions('hero'); 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 ( return (
<div className="gallery-page min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}> <div className="gallery-page min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
{/* Dynamic Favicon */} {/* Dynamic Favicon */}
@@ -573,6 +628,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Main Content */} {/* Main Content */}
<main className="container">{children}</main> <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 */}
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-surface"> <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"> <div className="container text-center px-4">
@@ -598,9 +657,31 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{brandingSettings.company_name} - {brandingSettings.company_tagline} {brandingSettings.company_name} - {brandingSettings.company_tagline}
</p> </p>
)} )}
{/* Legal Links */}
{/* 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"
>
<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"> <div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
{impressumPage?.use_external_url && impressumPage.external_url ? ( {showImpressum && (
impressumPage?.use_external_url && impressumPage.external_url ? (
<a <a
href={impressumPage.external_url} href={impressumPage.external_url}
target="_blank" target="_blank"
@@ -616,9 +697,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
> >
{t('legal.impressum')} {t('legal.impressum')}
</Link> </Link>
)
)} )}
{showImpressum && showDatenschutz && (
<span className="text-xs text-muted-theme">|</span> <span className="text-xs text-muted-theme">|</span>
{datenschutzPage?.use_external_url && datenschutzPage.external_url ? ( )}
{showDatenschutz && (
datenschutzPage?.use_external_url && datenschutzPage.external_url ? (
<a <a
href={datenschutzPage.external_url} href={datenschutzPage.external_url}
target="_blank" target="_blank"
@@ -634,10 +719,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
> >
{t('legal.datenschutz')} {t('legal.datenschutz')}
</Link> </Link>
)
)} )}
{guestIdentity?.identity && ( {guestIdentity?.identity && (
<> <>
<span className="text-xs text-muted-theme">|</span> {hasLegalLinks && <span className="text-xs text-muted-theme">|</span>}
<button <button
type="button" type="button"
className="text-xs text-muted-theme hover:text-theme transition-colors" className="text-xs text-muted-theme hover:text-theme transition-colors"
@@ -652,8 +738,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</> </>
)} )}
</div> </div>
)}
</div> </div>
</footer> </footer>
{/* Promotional banner (#440) — rendered below the footer when
branding_promo_position = 'below_footer'. */}
{promoPosition === 'below_footer' && promoSlot}
</div> </div>
); );
}; };
@@ -243,6 +243,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
logo_display_hero: settingsData.branding_logo_display_hero !== false, logo_display_hero: settingsData.branding_logo_display_hero !== false,
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text', logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
hide_powered_by: settingsData.branding_hide_powered_by === true, 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]); }, [settingsData]);
@@ -726,7 +736,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
) : null} ) : null}
<GalleryLayout <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} brandingSettings={brandingSettings}
headerStyle={data?.event?.header_style || theme.headerStyle} headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={true} showLogout={true}
+12 -2
View File
@@ -689,7 +689,8 @@
}, },
"footer": { "footer": {
"forgetMeConfirm": "Ihr Name und Ihre Auswahl werden aus dieser Galerie entfernt.", "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_one": "{{count}} Foto",
"photosCount_other": "{{count}} Fotos", "photosCount_other": "{{count}} Fotos",
@@ -1000,7 +1001,16 @@
"importExternal": "Aus externem Ordner importieren", "importExternal": "Aus externem Ordner importieren",
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.", "externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
"selectExternalFolder": "Externen Ordner unter /external-media auswählen", "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": { "settings": {
"title": "Systemeinstellungen", "title": "Systemeinstellungen",
+12 -2
View File
@@ -324,7 +324,8 @@
}, },
"footer": { "footer": {
"forgetMeConfirm": "Your name and selections will be removed from this gallery.", "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_one": "{{count}} photo",
"photosCount_other": "{{count}} photos", "photosCount_other": "{{count}} photos",
@@ -639,7 +640,16 @@
"importExternal": "Import from External Folder", "importExternal": "Import from External Folder",
"externalImportInfo": "All pictures from the selected folder will be imported.", "externalImportInfo": "All pictures from the selected folder will be imported.",
"selectExternalFolder": "Select external folder under /external-media", "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": { "settings": {
"title": "System Settings", "title": "System Settings",
+11 -1
View File
@@ -333,7 +333,8 @@
}, },
"footer": { "footer": {
"forgetMeConfirm": "Votre nom et vos sélections seront supprimés de cette galerie.", "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_many": "{{count}} photos",
"photosCount_one": "{{count}} photo", "photosCount_one": "{{count}} photo",
@@ -653,6 +654,15 @@
"generatedLabel": "Mot de passe de galerie généré automatiquement", "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.", "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é" "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": { "settings": {
+12 -2
View File
@@ -328,7 +328,8 @@
}, },
"footer": { "footer": {
"forgetMeConfirm": "Uw naam en selecties worden verwijderd uit deze galerij.", "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_one": "{{count}} foto",
"photosCount_other": "{{count}} foto's", "photosCount_other": "{{count}} foto's",
@@ -639,7 +640,16 @@
"importExternal": "Importeren vanuit externe map", "importExternal": "Importeren vanuit externe map",
"externalImportInfo": "Alle afbeeldingen uit de geselecteerde map worden geïmporteerd.", "externalImportInfo": "Alle afbeeldingen uit de geselecteerde map worden geïmporteerd.",
"selectExternalFolder": "Externe map selecteren onder /external-media", "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": { "settings": {
"title": "Systeeminstellingen", "title": "Systeeminstellingen",
+12 -2
View File
@@ -335,7 +335,8 @@
}, },
"footer": { "footer": {
"forgetMeConfirm": "O seu nome e seleções serão removidos desta galeria.", "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_many": "{{count}} fotos",
"photosCount_one": "{{count}} foto", "photosCount_one": "{{count}} foto",
@@ -655,7 +656,16 @@
"importExternal": "Importar de pasta externa", "importExternal": "Importar de pasta externa",
"externalImportInfo": "Todas as imagens da pasta selecionada serão importadas.", "externalImportInfo": "Todas as imagens da pasta selecionada serão importadas.",
"selectExternalFolder": "Selecionar pasta externa em /external-media", "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": { "settings": {
"title": "Configurações do Sistema", "title": "Configurações do Sistema",
+12 -2
View File
@@ -342,7 +342,8 @@
}, },
"footer": { "footer": {
"forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.", "forgetMeConfirm": "Ваше имя и выборки будут удалены из этой галереи.",
"forgetMe": "Забыть меня ({{name}})" "forgetMe": "Забыть меня ({{name}})",
"socials": "Социальные сети"
}, },
"photosCount_few": "{{count}} фото", "photosCount_few": "{{count}} фото",
"photosCount_many": "{{count}} фото", "photosCount_many": "{{count}} фото",
@@ -671,7 +672,16 @@
"importExternal": "Импорт из внешней папки", "importExternal": "Импорт из внешней папки",
"externalImportInfo": "Все изображения из выбранной папки будут импортированы.", "externalImportInfo": "Все изображения из выбранной папки будут импортированы.",
"selectExternalFolder": "Выберите внешнюю папку в /external-media", "selectExternalFolder": "Выберите внешнюю папку в /external-media",
"importFromSelectedFolder": "Импортировать из выбранной папки" "importFromSelectedFolder": "Импортировать из выбранной папки",
"promoBanner": {
"title": "Промо-баннер",
"help": "Выберите, как эта галерея отображает промо-баннер. «Наследовать» использует глобальную настройку; «Пользовательский» переопределяет для этого события; «Выключено» скрывает баннер.",
"mode_inherit": "Наследовать глобальную настройку",
"mode_custom": "Пользовательский для этого события",
"mode_off": "Выключено (скрыть для этого события)",
"placeholder": "Содержимое в Markdown (например, **Спецпредложение:** [записаться](https://example.com))",
"preview": "Предпросмотр"
}
}, },
"settings": { "settings": {
"title": "Системные настройки", "title": "Системные настройки",
+110 -1
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Save, Eye, Palette, Upload } from 'lucide-react'; import { Save, Eye, Palette, Upload } from 'lucide-react';
import { toast } from 'react-toastify'; 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 { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext'; import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -32,6 +32,13 @@ export const BrandingPage: React.FC = () => {
logo_display_mode: 'logo_and_text', logo_display_mode: 'logo_and_text',
hide_powered_by: false, hide_powered_by: false,
force_color_mode: null, 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); const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
@@ -349,6 +356,108 @@ export const BrandingPage: React.FC = () => {
</div> </div>
</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 className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
+22
View File
@@ -667,6 +667,28 @@ export const CMSPage: React.FC = () => {
)} )}
</div> </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 */} {/* Title */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
+63 -1
View File
@@ -57,7 +57,7 @@ const safeParseDate = (dateValue: unknown): Date | null => {
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; 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 { 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
@@ -284,6 +284,12 @@ export const EventDetailsPage: React.FC = () => {
photo_cap: number; photo_cap: number;
// Default photo sort // Default photo sort
default_photo_sort: string; 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); const [isEditing, setIsEditing] = useState(false);
@@ -321,6 +327,9 @@ export const EventDetailsPage: React.FC = () => {
photo_cap: 0, photo_cap: 0,
// Default photo sort // Default photo sort
default_photo_sort: 'upload_date_desc', default_photo_sort: 'upload_date_desc',
// Per-event promotional override (#440)
promo_mode: 'inherit',
promo_markdown: '',
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -576,6 +585,9 @@ export const EventDetailsPage: React.FC = () => {
photo_cap: event.photo_cap || 0, photo_cap: event.photo_cap || 0,
// Default photo sort // Default photo sort
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)
promo_mode: ((event as { promo_mode?: 'inherit' | 'custom' | 'off' }).promo_mode) || 'inherit',
promo_markdown: (event as { promo_markdown?: string }).promo_markdown || '',
}); });
setShowNewPassword(false); setShowNewPassword(false);
@@ -718,6 +730,10 @@ export const EventDetailsPage: React.FC = () => {
// Header style settings (decoupled from layout, #158) // Header style settings (decoupled from layout, #158)
header_style: currentTheme?.headerStyle || 'standard', header_style: currentTheme?.headerStyle || 'standard',
hero_divider_style: currentTheme?.heroDividerStyle || 'wave', 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 // Only include fields that have defined values
@@ -1365,6 +1381,52 @@ export const EventDetailsPage: React.FC = () => {
/> />
</div> </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 */} {/* Download Protection Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700"> <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"> <h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
+3
View File
@@ -10,6 +10,8 @@ export interface CMSPage {
logo_url: string | null; logo_url: string | null;
use_external_url: boolean; use_external_url: boolean;
external_url: string | null; external_url: string | null;
// Footer visibility (#441). True = link rendered in the gallery footer.
show_in_footer?: boolean;
updated_at: string; updated_at: string;
} }
@@ -20,6 +22,7 @@ export interface PublicCMSPage {
logo_url: string | null; logo_url: string | null;
use_external_url: boolean; use_external_url: boolean;
external_url: string | null; external_url: string | null;
show_in_footer?: boolean;
updated_at: string; updated_at: string;
} }
@@ -25,6 +25,14 @@ export interface PublicSettings {
* AdminDarkModeContext + ThemeContext both honor this. * AdminDarkModeContext + ThemeContext both honor this.
*/ */
branding_force_color_mode?: 'dark' | 'light' | null; 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; theme_config: any;
default_language: string; default_language: string;
enable_analytics: boolean; enable_analytics: boolean;
+10
View File
@@ -25,6 +25,16 @@ export interface BrandingSettings {
* `colorMode` override is ignored. `null` means no force (default behavior). * `colorMode` override is ignored. `null` means no force (default behavior).
*/ */
force_color_mode?: 'dark' | 'light' | null; 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 { export interface ThemeSettings {