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 <img> 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.
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
// <img> 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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user