From 5f1f4c2b3dc39a70adfc3b3f6d60dd3136601641 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:20:48 +0200 Subject: [PATCH] refactor(slideshow): replace per-event-type preset with a picpeak-wide one The slideshow display preset (transition / interval / speed / color filter) was set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single picpeak-wide default in Settings -> Slideshow ("Default style for new slideshows"). New events seed their show_* columns from this global preset (was: from the event type's slideshow_preset); the per-event override is unchanged. - Removed event_types.slideshow_preset usage everywhere (EventTypeModal section, eventTypes.service types, eventTypeService whitelist, adminEventTypes validators/POST). The DB column from migration 138 is left inert. - Global preset stored in app_settings (slideshow_interval_ms/transition/ transition_ms/colorfilter), saved via PUT /admin/settings/slideshow. - adminEvents create-seeding now reads the global preset (getAppSetting) instead of the event type. - en/de: presetTitle + presetHint. --- backend/src/routes/adminEventTypes.js | 4 -- backend/src/routes/adminEvents.js | 45 +++++------- backend/src/routes/adminSettings.js | 17 +++++ backend/src/services/eventTypeService.js | 8 +-- .../admin/SlideshowGlobalDefaultsCard.tsx | 69 +++++++++++++++++++ frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + frontend/src/pages/admin/EventTypesPage.tsx | 42 +---------- frontend/src/services/eventTypes.service.ts | 6 -- frontend/src/services/slideshow.service.ts | 15 ++-- 10 files changed, 118 insertions(+), 92 deletions(-) diff --git a/backend/src/routes/adminEventTypes.js b/backend/src/routes/adminEventTypes.js index db94b4ae..f18f4286 100644 --- a/backend/src/routes/adminEventTypes.js +++ b/backend/src/routes/adminEventTypes.js @@ -89,7 +89,6 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ body('emoji').optional().trim(), body('theme_preset').optional().trim(), body('theme_config').optional(), - body('slideshow_preset').optional(), body('display_order').optional().isInt({ min: 0 }) ], async (req, res) => { try { @@ -104,7 +103,6 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ emoji, theme_preset, theme_config, - slideshow_preset, display_order } = req.body; @@ -114,7 +112,6 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ emoji, theme_preset, theme_config, - slideshow_preset, display_order }); @@ -153,7 +150,6 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [ body('emoji').optional().trim(), body('theme_preset').optional().trim(), body('theme_config').optional(), - body('slideshow_preset').optional(), body('display_order').optional().isInt({ min: 0 }), body('is_active').optional().isBoolean() ], async (req, res) => { diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 13557a3f..507348e7 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -26,6 +26,7 @@ const { hasColumnCached } = require('../utils/schemaCache'); const { validateFileType } = require('../utils/fileSecurityUtils'); const { requireEventOwnership } = require('../middleware/ownership'); const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const { getAppSetting } = require('../utils/appSettings'); const { getFrontendBaseUrl } = require('../utils/frontendUrl'); const downloadZipService = require('../services/downloadZipService'); @@ -673,40 +674,26 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); // Insert into database - // Seed the new event's Live Slideshow settings from the event TYPE's preset - // (migration 138). The admin configures "weddings fade slowly with our - // white logo, sepia" once on the type; every new wedding inherits it. The - // share token is NOT seeded — the link is still minted on demand. Guarded + // Seed the new event's Live Slideshow display style from the PICPEAK-WIDE + // preset (app_settings, Settings → Slideshow). New events inherit it and the + // admin can still override per event. Watermark is left NULL = inherit the + // global watermark; the share token is minted on demand, not seeded. Guarded // so un-migrated installs (mid-branch) don't reference missing columns. let slideshowSeed = {}; if (await hasColumnCached('events', 'show_interval_ms')) { try { - const type = await eventTypeService.getEventTypeBySlugPrefix(event_type); - const preset = type?.slideshow_preset - ? (typeof type.slideshow_preset === 'string' ? JSON.parse(type.slideshow_preset) : type.slideshow_preset) - : null; - if (preset && typeof preset === 'object') { - const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined); - const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); - // Tri-state watermark: 'on'/'off' seed an explicit override; 'inherit' - // (or unset) leaves the column NULL so the event follows the global. - let watermarkSeed; - if (preset.watermark === 'on' || preset.watermark === true) watermarkSeed = formatBoolean(true); - else if (preset.watermark === 'off' || preset.watermark === false) watermarkSeed = formatBoolean(false); - const seed = { - show_interval_ms: intP(preset.interval_ms, 1000, 120000), - show_transition: oneOf(preset.transition, SLIDESHOW_TRANSITIONS), - show_transition_ms: intP(preset.transition_ms, 100, 5000), - show_watermark: watermarkSeed, - show_colorfilter: oneOf(preset.colorfilter, SLIDESHOW_COLORFILTERS), - }; - // Only carry through fields the preset actually set. - for (const [k, v] of Object.entries(seed)) { - if (v !== undefined) slideshowSeed[k] = v; - } - } + const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined); + const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); + const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000); + const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS); + const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000); + const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS); + if (i !== undefined) slideshowSeed.show_interval_ms = i; + if (tr) slideshowSeed.show_transition = tr; + if (tms !== undefined) slideshowSeed.show_transition_ms = tms; + if (cf) slideshowSeed.show_colorfilter = cf; } catch (e) { - logger.warn('Failed to seed slideshow settings from event type preset', { error: e.message }); + logger.warn('Failed to seed slideshow settings from global preset', { error: e.message }); } } diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 22a2d843..06d9213c 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -302,6 +302,23 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r if (has('slideshow_fit')) { push('slideshow_fit', req.body.slideshow_fit === 'contain' ? 'contain' : 'cover'); } + // Picpeak-wide display preset (default style new events inherit). + if (has('slideshow_interval_ms')) { + const n = Math.min(120000, Math.max(1000, Math.round(Number(req.body.slideshow_interval_ms) || 5000))); + push('slideshow_interval_ms', n); + } + if (has('slideshow_transition')) { + const allowed = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; + push('slideshow_transition', allowed.includes(req.body.slideshow_transition) ? req.body.slideshow_transition : 'crossfade'); + } + if (has('slideshow_transition_ms')) { + const n = Math.min(5000, Math.max(100, Math.round(Number(req.body.slideshow_transition_ms) || 800))); + push('slideshow_transition_ms', n); + } + if (has('slideshow_colorfilter')) { + const allowed = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; + push('slideshow_colorfilter', allowed.includes(req.body.slideshow_colorfilter) ? req.body.slideshow_colorfilter : 'none'); + } if (has('slideshow_watermark_enabled')) push('slideshow_watermark_enabled', !!req.body.slideshow_watermark_enabled); if (has('slideshow_watermark_source')) { const v = ['logo', 'logo_dark', 'favicon', 'event'].includes(req.body.slideshow_watermark_source) ? req.body.slideshow_watermark_source : 'logo'; diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js index 0ba052b4..1dbd8308 100644 --- a/backend/src/services/eventTypeService.js +++ b/backend/src/services/eventTypeService.js @@ -100,8 +100,7 @@ const createEventType = async (eventTypeData) => { emoji, theme_preset, theme_config, - display_order, - slideshow_preset + display_order } = eventTypeData; // Normalize slug_prefix @@ -128,7 +127,6 @@ const createEventType = async (eventTypeData) => { emoji: emoji || '📷', theme_preset: theme_preset || 'default', theme_config: theme_config ? JSON.stringify(theme_config) : null, - slideshow_preset: slideshow_preset ? JSON.stringify(slideshow_preset) : null, display_order: finalDisplayOrder, is_system: false, is_active: true, @@ -193,10 +191,6 @@ const updateEventType = async (id, updates) => { updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null; } - if (updates.slideshow_preset !== undefined) { - updateData.slideshow_preset = updates.slideshow_preset ? JSON.stringify(updates.slideshow_preset) : null; - } - if (updates.display_order !== undefined) { updateData.display_order = updates.display_order; } diff --git a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx index b42b0c39..39a95d27 100644 --- a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx +++ b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx @@ -17,12 +17,18 @@ import { SLIDESHOW_WATERMARK_POSITIONS, SLIDESHOW_WATERMARK_STYLES, SLIDESHOW_FITS, + SLIDESHOW_TRANSITIONS, + SLIDESHOW_COLORFILTERS, type SlideshowGlobalDefaults, } from '../../services/slideshow.service'; import { WatermarkSourcePicker } from './WatermarkSourcePicker'; const DEFAULTS: SlideshowGlobalDefaults = { slideshow_fit: 'cover', + slideshow_interval_ms: 5000, + slideshow_transition: 'crossfade', + slideshow_transition_ms: 800, + slideshow_colorfilter: 'none', slideshow_watermark_enabled: false, slideshow_watermark_source: 'logo', slideshow_watermark_position: 'bottom-right', @@ -46,6 +52,10 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => { if (cancelled || !s) return; setVal({ slideshow_fit: s.slideshow_fit ?? DEFAULTS.slideshow_fit, + slideshow_interval_ms: s.slideshow_interval_ms ?? DEFAULTS.slideshow_interval_ms, + slideshow_transition: s.slideshow_transition ?? DEFAULTS.slideshow_transition, + slideshow_transition_ms: s.slideshow_transition_ms ?? DEFAULTS.slideshow_transition_ms, + slideshow_colorfilter: s.slideshow_colorfilter ?? DEFAULTS.slideshow_colorfilter, slideshow_watermark_enabled: s.slideshow_watermark_enabled ?? DEFAULTS.slideshow_watermark_enabled, slideshow_watermark_source: s.slideshow_watermark_source ?? DEFAULTS.slideshow_watermark_source, slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position, @@ -99,6 +109,65 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {

+ {/* Default display style new slideshows inherit (override per event) */} +
+

+ {t('slideshow.presetTitle', 'Default style for new slideshows')} +

+

+ {t('slideshow.presetHint', 'Applied to events created from now on; each event can still override it.')} +

+
+
+ + +
+
+ + setVal({ ...val, slideshow_interval_ms: Math.min(120, Math.max(1, parseInt(e.target.value, 10) || 5)) * 1000 })} + className={inputClass} + /> +
+
+ + setVal({ ...val, slideshow_transition_ms: Math.min(5000, Math.max(100, parseInt(e.target.value, 10) || 800)) })} + className={inputClass} + /> +
+
+ + +
+
+
+
- {/* Live Slideshow preset (migration 138). New events of this type - inherit these slideshow defaults; admins can still override - per event on the event detail page. Gated behind the - `slideshow` feature flag. */} - {slideshowEnabled && ( -
- -

- {t('eventTypes.form.slideshowPresetHint', 'Default slideshow style for new events of this type.')} -

- -
- )} - {/* Active toggle for editing */} {isEditing && (