From dea5e0f8a6421c056868c2d9bea11e5bf1ee106a Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Fri, 19 Jun 2026 22:10:56 +0200
Subject: [PATCH] feat(slideshow): backend api for live slideshow
- public GET /gallery/:slug/show/:token/session (validates token, mints a
slideshow-scoped gallery JWT + sets the per-slug cookie so
requests
authorize) and /state (cheap settings + photo-count poll). Reuse /photos for
the list; skip the view-log for slideshow access so the kiosk does not pollute
visitor analytics.
- admin slideshow link generate/disable + live style PATCH on events.
- event-type slideshow_preset whitelisted in CRUD; create-event seeds the new
event's show_* columns from the type preset.
- global watermark defaults via PUT /admin/settings/slideshow; watermark cascade
(global default -> per-event override) resolving the light/dark/favicon/event
logo url.
---
backend/src/routes/adminEventTypes.js | 4 +
backend/src/routes/adminEvents.js | 198 +++++++++++++++++++++++
backend/src/routes/adminSettings.js | 38 +++++
backend/src/routes/gallery.js | 178 +++++++++++++++++++-
backend/src/services/eventTypeService.js | 8 +-
5 files changed, 418 insertions(+), 8 deletions(-)
diff --git a/backend/src/routes/adminEventTypes.js b/backend/src/routes/adminEventTypes.js
index f18f4286..db94b4ae 100644
--- a/backend/src/routes/adminEventTypes.js
+++ b/backend/src/routes/adminEventTypes.js
@@ -89,6 +89,7 @@ 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 {
@@ -103,6 +104,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
emoji,
theme_preset,
theme_config,
+ slideshow_preset,
display_order
} = req.body;
@@ -112,6 +114,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
emoji,
theme_preset,
theme_config,
+ slideshow_preset,
display_order
});
@@ -150,6 +153,7 @@ 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 fc880c6b..f7e513ae 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -672,10 +672,52 @@ 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
+ // 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_watermark_source: oneOf(preset.watermark_source, SLIDESHOW_WATERMARK_SOURCES),
+ show_watermark_position: oneOf(preset.watermark_position, SLIDESHOW_WATERMARK_POSITIONS),
+ show_watermark_opacity: intP(preset.watermark_opacity, 0, 100),
+ show_watermark_style: oneOf(preset.watermark_style, SLIDESHOW_WATERMARK_STYLES),
+ 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;
+ }
+ }
+ } catch (e) {
+ logger.warn('Failed to seed slideshow settings from event type preset', { error: e.message });
+ }
+ }
+
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
+ ...slideshowSeed,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
@@ -1854,6 +1896,162 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), r
}
});
+// ---------------------------------------------------------------------------
+// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
+// events that auto-picks-up new uploads (migration 137). Mirrors the
+// client-access second-token pattern: the link is minted on demand, rotatable
+// and disable-able, independent of the gallery password / share link.
+// ---------------------------------------------------------------------------
+
+// Allowed slide transition styles (kept in sync with the SlideshowPage).
+// dipwhite/dipblack = fade through highlights / lowlights between images.
+const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
+// Allowed per-slide color filters.
+const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
+// Allowed watermark logo sources + corners.
+const SLIDESHOW_WATERMARK_SOURCES = ['logo', 'logo_dark', 'favicon', 'event'];
+const SLIDESHOW_WATERMARK_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
+// 'white' recolors the logo white; 'original' keeps its own colors (for boxed
+// / colored logos that would otherwise whiten into a solid blob).
+const SLIDESHOW_WATERMARK_STYLES = ['white', 'original'];
+
+// Build the public slideshow URL for a freshly-minted/existing token.
+async function buildSlideshowUrl(slug, token) {
+ if (!token) return null;
+ const base = await getFrontendBaseUrl();
+ return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`;
+}
+
+// Fetch the event respecting the editor-role ownership scope (requireEventOwnership
+// already gates the route; this re-applies the created_by filter for editors so the
+// 404 is identical to the rest of this file).
+async function loadOwnedEvent(req) {
+ let q = db('events').where('id', req.params.id);
+ if (req.admin.roleName === 'editor') {
+ q = q.where('created_by', req.admin.id);
+ }
+ return q.first();
+}
+
+// Generate (or rotate) the slideshow share token. Idempotent in intent: each
+// call mints a fresh token, which both "Generate" (first time) and "Regenerate"
+// (rotate, kills the old link) use.
+router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
+ try {
+ const event = await loadOwnedEvent(req);
+ if (!event) {
+ return res.status(404).json({ error: 'Event not found' });
+ }
+
+ const token = crypto.randomBytes(32).toString('hex');
+ await db('events').where('id', req.params.id).update({
+ show_share_token: token,
+ updated_at: new Date()
+ });
+
+ await logActivity('slideshow_link_generated',
+ { eventName: event.event_name, rotated: Boolean(event.show_share_token) },
+ req.params.id,
+ { type: 'admin', id: req.admin.id, name: req.admin.username }
+ );
+
+ res.json({
+ show_share_token: token,
+ slideshow_url: await buildSlideshowUrl(event.slug, token)
+ });
+ } catch (error) {
+ logger.error('Error generating slideshow link', { error: error.message });
+ res.status(500).json({ error: 'Failed to generate slideshow link' });
+ }
+});
+
+// Disable the slideshow link (null the token). The public /show/ route dies on
+// its next poll, killing any projector currently pointed at the old link.
+router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
+ try {
+ const event = await loadOwnedEvent(req);
+ if (!event) {
+ return res.status(404).json({ error: 'Event not found' });
+ }
+
+ await db('events').where('id', req.params.id).update({
+ show_share_token: null,
+ updated_at: new Date()
+ });
+
+ await logActivity('slideshow_link_disabled',
+ { eventName: event.event_name },
+ req.params.id,
+ { type: 'admin', id: req.admin.id, name: req.admin.username }
+ );
+
+ res.json({ show_share_token: null });
+ } catch (error) {
+ logger.error('Error disabling slideshow link', { error: error.message });
+ res.status(500).json({ error: 'Failed to disable slideshow link' });
+ }
+});
+
+// Update the LIVE slideshow settings (display time / transition style / speed).
+// A running projector picks these up via the show-page settings poll within a
+// few seconds — no need to regenerate the link.
+router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
+ body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }),
+ body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
+ body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
+ body('show_watermark').optional({ nullable: true }),
+ body('show_watermark_source').optional().isIn(SLIDESHOW_WATERMARK_SOURCES),
+ body('show_watermark_position').optional().isIn(SLIDESHOW_WATERMARK_POSITIONS),
+ body('show_watermark_opacity').optional().isInt({ min: 0, max: 100 }),
+ body('show_watermark_style').optional().isIn(SLIDESHOW_WATERMARK_STYLES),
+ body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() });
+ }
+
+ const event = await loadOwnedEvent(req);
+ if (!event) {
+ return res.status(404).json({ error: 'Event not found' });
+ }
+
+ const updates = { updated_at: new Date() };
+ if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10);
+ if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition;
+ if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10);
+ // Tri-state: explicit null = inherit the global default.
+ if (req.body.show_watermark !== undefined) {
+ updates.show_watermark = req.body.show_watermark === null
+ ? null
+ : formatBoolean(parseBooleanInput(req.body.show_watermark, false));
+ }
+ if (req.body.show_watermark_source !== undefined) updates.show_watermark_source = req.body.show_watermark_source;
+ if (req.body.show_watermark_position !== undefined) updates.show_watermark_position = req.body.show_watermark_position;
+ if (req.body.show_watermark_opacity !== undefined) updates.show_watermark_opacity = parseInt(req.body.show_watermark_opacity, 10);
+ if (req.body.show_watermark_style !== undefined) updates.show_watermark_style = req.body.show_watermark_style;
+ if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
+
+ await db('events').where('id', req.params.id).update(updates);
+
+ res.json({
+ show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000,
+ show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
+ show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
+ show_watermark: updates.show_watermark ?? event.show_watermark ?? false,
+ show_watermark_source: updates.show_watermark_source ?? event.show_watermark_source ?? 'logo',
+ show_watermark_position: updates.show_watermark_position ?? event.show_watermark_position ?? 'bottom-right',
+ show_watermark_opacity: updates.show_watermark_opacity ?? event.show_watermark_opacity ?? 60,
+ show_watermark_style: updates.show_watermark_style ?? event.show_watermark_style ?? 'white',
+ show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
+ });
+ } catch (error) {
+ logger.error('Error updating slideshow settings', { error: error.message });
+ res.status(500).json({ error: 'Failed to update slideshow settings' });
+ }
+});
+
// Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index 8402fcdd..a762e2de 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -290,6 +290,44 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
}
});
+// Global Live Slideshow defaults (migration 138). The per-event watermark is
+// tri-state (events.show_watermark NULL = inherit these). Read via the generic
+// GET /:type ('slideshow'); this is the typed write.
+router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (req, res) => {
+ try {
+ const updates = [];
+ const push = (key, value) => updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'slideshow' });
+ const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k);
+
+ 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';
+ push('slideshow_watermark_source', v);
+ }
+ if (has('slideshow_watermark_position')) {
+ const allowed = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
+ const v = allowed.includes(req.body.slideshow_watermark_position) ? req.body.slideshow_watermark_position : 'bottom-right';
+ push('slideshow_watermark_position', v);
+ }
+ if (has('slideshow_watermark_opacity')) {
+ const n = Math.min(100, Math.max(0, Math.round(Number(req.body.slideshow_watermark_opacity) || 0)));
+ push('slideshow_watermark_opacity', n);
+ }
+ if (has('slideshow_watermark_style')) {
+ const v = ['white', 'original'].includes(req.body.slideshow_watermark_style) ? req.body.slideshow_watermark_style : 'white';
+ push('slideshow_watermark_style', v);
+ }
+
+ for (const u of updates) {
+ await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
+ }
+ res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
+ } catch (error) {
+ console.error('Slideshow settings save error:', error);
+ res.status(500).json({ error: 'Failed to save slideshow settings' });
+ }
+});
+
// Get settings by type
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 0ea362e0..5bc70798 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -1,4 +1,5 @@
const express = require('express');
+const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver');
@@ -24,6 +25,8 @@ const {
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage');
+const { setGalleryAuthCookies } = require('../utils/tokenUtils');
+const { getSetting } = require('../services/settingsService');
const fs = require('fs');
// Get storage path from environment or default
@@ -212,6 +215,162 @@ router.get('/:slug/info', async (req, res) => {
}
});
+// ---------------------------------------------------------------------------
+// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface
+// (migration 137). The token in the URL IS the secret (no gallery password),
+// so these routes are unauthenticated except for the token match itself. The
+// slideshow shows ALL public/visible, finished photos — exactly the guest
+// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT,
+// the page reuses the normal /photos + image endpoints unchanged.
+// ---------------------------------------------------------------------------
+
+// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
+// guest filter in GET /:slug/photos so the live count matches the rendered set.
+function slideshowPhotosQuery(eventId) {
+ return db('photos')
+ .where('photos.event_id', eventId)
+ .where(function() {
+ this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
+ })
+ .where(function() {
+ this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
+ });
+}
+
+// Resolve an active slideshow by slug + token. Returns the event row, or null
+// when the link is missing/rotated/disabled or the gallery isn't live (archived
+// / draft / inactive / expired) — every one of those collapses to a 404 so a
+// dead link reveals nothing and stops any projector on its next poll.
+async function resolveSlideshow(slug, token) {
+ if (!token) return null;
+ const event = await db('events')
+ .where({
+ slug,
+ show_share_token: token,
+ is_active: formatBoolean(true),
+ is_archived: formatBoolean(false),
+ is_draft: formatBoolean(false)
+ })
+ .first();
+ if (!event) return null;
+ if (event.expires_at && new Date(event.expires_at) < new Date()) return null;
+ return event;
+}
+
+// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style
+// watermark (a white, semi-transparent corner logo). The logo URL is resolved
+// from the chosen source so the kiosk renders it without knowing about
+// branding/event internals; null url = nothing to overlay.
+async function slideshowSettings(event) {
+ // Watermark cascade: per-event `show_watermark` overrides the global default,
+ // NULL inherits it. When inheriting, the source/position/opacity also come
+ // from the global settings; when overriding, from the event's own columns.
+ const wm = event.show_watermark;
+ const inherit = (wm === null || wm === undefined);
+ const enabled = inherit
+ ? (await getSetting('slideshow_watermark_enabled', false)) === true
+ : (wm === true || wm === 1 || wm === '1');
+ let watermark = null;
+ if (enabled) {
+ const source = inherit
+ ? (await getSetting('slideshow_watermark_source', 'logo'))
+ : (event.show_watermark_source || 'logo');
+ const position = inherit
+ ? (await getSetting('slideshow_watermark_position', 'bottom-right'))
+ : (event.show_watermark_position || 'bottom-right');
+ const opacity = inherit
+ ? (await getSetting('slideshow_watermark_opacity', 60))
+ : (event.show_watermark_opacity ?? 60);
+ const style = inherit
+ ? (await getSetting('slideshow_watermark_style', 'white'))
+ : (event.show_watermark_style || 'white');
+ // Resolve the chosen logo to a URL. Branding assets come from settings;
+ // the event source uses the event's own hero logo.
+ let url;
+ if (source === 'event') {
+ url = event.hero_logo_url || null;
+ } else if (source === 'logo_dark') {
+ url = await getSetting('branding_logo_url_dark', null);
+ } else if (source === 'favicon') {
+ url = await getSetting('branding_favicon_url', null);
+ } else {
+ url = await getSetting('branding_logo_url', null);
+ }
+ if (url) {
+ watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white' };
+ }
+ }
+ return {
+ interval_ms: event.show_interval_ms || 5000,
+ transition: event.show_transition || 'crossfade',
+ transition_ms: event.show_transition_ms || 800,
+ colorfilter: event.show_colorfilter || 'none',
+ watermark,
+ };
+}
+
+// Open a slideshow session: validate the token and mint a short-lived gallery
+// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo /
+// image endpoints → visible photos only, no client-only/hidden). The page
+// stores this token and the existing axios interceptor injects it.
+router.get('/:slug/show/:token/session', handleAsync(async (req, res) => {
+ const { slug, token } = req.params;
+ const event = await resolveSlideshow(slug, token);
+ if (!event) {
+ throw new NotFoundError('Slideshow');
+ }
+
+ const sessionToken = jwt.sign({
+ eventId: event.id,
+ eventSlug: event.slug,
+ type: 'gallery',
+ accessLevel: 'slideshow',
+ loginTime: Date.now()
+ }, process.env.JWT_SECRET, {
+ expiresIn: '12h',
+ issuer: 'picpeak-auth'
+ });
+
+ //
tags can't carry an Authorization header, so the photo/thumbnail/
+ // preview endpoints authenticate via the per-slug gallery cookie. Set it
+ // here so the kiosk's image requests are authorized with zero extra wiring.
+ setGalleryAuthCookies(res, sessionToken, event.slug);
+
+ const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
+
+ res.json({
+ token: sessionToken,
+ event: {
+ event_name: event.event_name,
+ event_type: event.event_type,
+ color_theme: event.color_theme
+ },
+ settings: await slideshowSettings(event),
+ photo_count: parseInt(count, 10) || 0,
+ expires_at: event.expires_at || null
+ });
+}));
+
+// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show):
+// current settings + the visible photo count. The page diffs photo_count to
+// decide when to refetch the full list, and re-reads settings so admin changes
+// take effect live. A dead/disabled link 404s here → the projector stops.
+router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
+ const { slug, token } = req.params;
+ const event = await resolveSlideshow(slug, token);
+ if (!event) {
+ throw new NotFoundError('Slideshow');
+ }
+
+ const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
+
+ res.json({
+ ...(await slideshowSettings(event)),
+ photo_count: parseInt(count, 10) || 0,
+ expires_at: event.expires_at || null
+ });
+}));
+
// Get all photos
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => {
try {
@@ -418,13 +577,18 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
categoryMap[cat.id] = cat;
});
- // Log view
- await db('access_logs').insert({
- event_id: req.event.id,
- ip_address: req.ip,
- user_agent: req.headers['user-agent'],
- action: 'view'
- });
+ // Log view — but NOT for the Live Slideshow kiosk. A running projector
+ // refetches this list on every new-upload poll, which would massively
+ // inflate total_views / unique_visitors. The slideshow is explicitly
+ // excluded from real visitor analytics (migration 137 design).
+ if (req.accessLevel !== 'slideshow') {
+ await db('access_logs').insert({
+ event_id: req.event.id,
+ ip_address: req.ip,
+ user_agent: req.headers['user-agent'],
+ action: 'view'
+ });
+ }
// Include protection settings in response
const protectionSettings = {
diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js
index 1dbd8308..0ba052b4 100644
--- a/backend/src/services/eventTypeService.js
+++ b/backend/src/services/eventTypeService.js
@@ -100,7 +100,8 @@ const createEventType = async (eventTypeData) => {
emoji,
theme_preset,
theme_config,
- display_order
+ display_order,
+ slideshow_preset
} = eventTypeData;
// Normalize slug_prefix
@@ -127,6 +128,7 @@ 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,
@@ -191,6 +193,10 @@ 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;
}