diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1d5bb..1876a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.15.3 - 2026-10-15 + +### Changed + +- Replaced the canvas-size duplicate action with reversible disable/enable controls. Disabled sizes remain assigned to existing templates and playlists but cannot be selected for new ones. + ## 2.15.2 - 2026-10-15 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index 68b4701..4fd3d52 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.15.2", + "version": "2.15.3", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 4bb6ec4..c1e3337 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.15.2", + "version": "2.15.3", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 75c7d04..4bc5295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.15.2", + "version": "2.15.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.15.2", + "version": "2.15.3", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 91376ec..d416c62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.15.2", + "version": "2.15.3", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/admin.js b/src/data/admin.js index 87ef535..4473879 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -4,7 +4,7 @@ const { fetchPagedRows } = require('#src/data/utils'); async function fetchAdminData(pool) { const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC'); - const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC'); + const [canvasSizes] = await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC'); const [templates] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height @@ -141,7 +141,7 @@ async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sor async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by, + selectSql: `SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by, (SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC`, diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index 636fa7c..9f23c7b 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -3,14 +3,17 @@ const { fetchPagedRows } = require('#src/data/utils'); const MAX_CANVAS_SIZE_DIMENSION = 16384; -async function fetchCanvasSizesData(pool) { - const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC'); +async function fetchCanvasSizesData(pool, includeDisabledId) { + const disabledId = Number(includeDisabledId); + const [canvasSizes] = disabledId > 0 + ? await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE enabled = 1 OR id = ? ORDER BY width ASC, height ASC, name ASC', [disabledId]) + : await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE enabled = 1 ORDER BY width ASC, height ASC, name ASC'); return { canvasSizes }; } async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC', + selectSql: 'SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC', countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes', searchColumns: ['name', 'width', 'height'], searchTerm: searchTerm, @@ -30,7 +33,7 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s } async function fetchCanvasSizeById(pool, id) { - const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]); return rows[0] || null; } diff --git a/src/data/templates.js b/src/data/templates.js index 596f75f..197f13a 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -264,13 +264,18 @@ async function buildTemplatePayload(pool, req, existingTemplate) { let resolvedCanvasSizeId = canvasSizeId; if (resolvedCanvasSizeId) { - const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]); + const [canvasSizes] = await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]); const canvasSize = canvasSizes[0]; if (!canvasSize) { const error = new Error('Canvas size not found.'); error.statusCode = 400; throw error; } + if ((canvasSize.enabled === 0 || canvasSize.enabled === false) && (!existingTemplate || Number(existingTemplate.canvas_size_id) !== Number(canvasSize.id))) { + const error = new Error('Canvas size is disabled.'); + error.statusCode = 400; + throw error; + } canvasWidth = Number(canvasSize.width); canvasHeight = Number(canvasSize.height); } else { diff --git a/src/db/index.js b/src/db/index.js index 20b0d60..c655820 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -29,6 +29,7 @@ async function ensureSchema(pool, options) { name VARCHAR(255) NOT NULL, width INT NOT NULL, height INT NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/src/db/migrations.js b/src/db/migrations.js index eac5b11..a9c7cdf 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -677,6 +677,13 @@ const VERSIONED_MIGRATIONS = [ await ensureColumn(pool, 'c_playlist_slides', 'group_mode', "VARCHAR(16) NOT NULL DEFAULT 'select'", 'group_shuffle'); await ensureColumn(pool, 'c_playlist_slides', 'group_repeat_count', 'INT NOT NULL DEFAULT 1', 'group_mode'); } + }, + { + version: '2.15.3', + label: 'v2.15.3 canvas size enable state', + run: async function (pool) { + await ensureColumn(pool, 'c_canvas_sizes', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'height'); + } } ]; diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 20804f4..4a999e7 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -36,7 +36,6 @@ module.exports = function registerContentRoutes(app, deps) { const hasAnyPermission = deps.hasAnyPermission; const { buildPagination } = require('#src/web/lib/pagination'); const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate'); - const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate'); const LIST_PAGE_SIZE = 25; const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024; @@ -337,7 +336,11 @@ module.exports = function registerContentRoutes(app, deps) { duplicateIndex += 1; } - res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser, null, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES)); + const duplicateTemplate = buildDuplicateTemplate(template, duplicateName); + if (!canvasSizes.canvasSizes.some((size) => Number(size.id) === Number(duplicateTemplate.canvas_size_id))) { + duplicateTemplate.canvas_size_id = null; + } + res.send(pages.renderTemplateAddPage(duplicateTemplate, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser, null, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES)); } catch (error) { next(error); } @@ -749,7 +752,7 @@ module.exports = function registerContentRoutes(app, deps) { } template.region_usage = await fetchTemplateRegionUsage(template); template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0; - const sizeData = await common.fetchCanvasSizesData(pool); + const sizeData = await common.fetchCanvasSizesData(pool, template.canvas_size_id); await syncMediaAssetsFromDirectory(pool, deps.uploadDir); const mediaAssets = await fetchMediaAssets(pool); const settings = await fetchAppSettings(pool); @@ -882,26 +885,6 @@ module.exports = function registerContentRoutes(app, deps) { res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser)); }); - app.get('/canvas-sizes/:id/duplicate', requirePermission('canvas-sizes.read'), requirePermission('canvas-sizes.create'), async function (req, res, next) { - try { - const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); - if (!canvasSize) { - return res.status(404).send('Canvas size not found'); - } - - let duplicateName = buildDuplicateCanvasSizeName(canvasSize.name); - let duplicateIndex = 2; - while (await common.fetchDuplicateName(pool, 'c_canvas_sizes', duplicateName)) { - duplicateName = buildDuplicateCanvasSizeName(canvasSize.name) + ' (' + duplicateIndex + ')'; - duplicateIndex += 1; - } - - res.send(pages.renderCanvasSizeAddPage(buildDuplicateCanvasSize(canvasSize, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser)); - } catch (error) { - next(error); - } - }); - app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) { try { const payload = common.buildCanvasSizePayload(req, null); @@ -975,6 +958,21 @@ module.exports = function registerContentRoutes(app, deps) { } }); + app.post('/canvas-sizes/:id/toggle', requirePermission('canvas-sizes.update'), async function (req, res, next) { + try { + const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); + if (!canvasSize) { + return res.status(404).send('Canvas size not found'); + } + const enabled = canvasSize.enabled === 0 || canvasSize.enabled === false ? 1 : 0; + await pool.query('UPDATE c_canvas_sizes SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), canvasSize.id]); + if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: enabled ? 'canvas-size.enabled' : 'canvas-size.disabled', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: canvasSize.name }); + res.redirect('/canvas-sizes?message=' + encodeURIComponent(enabled ? 'Canvas size enabled.' : 'Canvas size disabled.')); + } catch (error) { + next(error); + } + }); + app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) { try { const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 573db76..35e3a7c 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -134,6 +134,9 @@ module.exports = function registerPlaylistRoutes(app, deps) { throw createValidationError('Canvas size not found.'); } requestedCanvasId = Number(canvasSize.id); + if ((canvasSize.enabled === 0 || canvasSize.enabled === false) && (!shouldUpdatePlaylist || Number(playlist.canvas_id) !== requestedCanvasId)) { + throw createValidationError('Canvas size is disabled.'); + } if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) { throw new Error('Canvas size cannot be changed after the playlist is created.'); } @@ -387,6 +390,9 @@ module.exports = function registerPlaylistRoutes(app, deps) { if (!canvasSize) { return res.status(400).send('Canvas size not found.'); } + if (canvasSize.enabled === 0 || canvasSize.enabled === false) { + return res.status(400).send('Canvas size is disabled.'); + } const actorId = getAuditUserId(req); await connection.beginTransaction(); const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]); diff --git a/src/web/routes/signage/canvas-sizes/duplicate.js b/src/web/routes/signage/canvas-sizes/duplicate.js deleted file mode 100644 index 4b92cc0..0000000 --- a/src/web/routes/signage/canvas-sizes/duplicate.js +++ /dev/null @@ -1,21 +0,0 @@ -// Canvas size duplicate helpers. - -function buildDuplicateCanvasSizeName(name) { - const source = String(name || 'Canvas size').trim() || 'Canvas size'; - return 'Copy of ' + source; -} - -function buildDuplicateCanvasSize(canvasSize, duplicateName) { - const source = canvasSize || {}; - return { - id: null, - name: String(duplicateName || buildDuplicateCanvasSizeName(source.name)).trim(), - width: Math.max(1, Number(source.width || 1920)), - height: Math.max(1, Number(source.height || 1080)) - }; -} - -module.exports = { - buildDuplicateCanvasSizeName, - buildDuplicateCanvasSize -}; \ No newline at end of file diff --git a/src/web/routes/signage/canvas-sizes/list.js b/src/web/routes/signage/canvas-sizes/list.js index 5df0b34..18b3be6 100644 --- a/src/web/routes/signage/canvas-sizes/list.js +++ b/src/web/routes/signage/canvas-sizes/list.js @@ -8,6 +8,7 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) { name: size.name, width: size.width, height: size.height, + enabled: size.enabled !== 0 && size.enabled !== false, templateCount: size.template_count !== undefined ? Number(size.template_count) || 0 : (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index 8566be3..c9cba5c 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -228,8 +228,11 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null; const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId; const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null; - const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, { - label: formatCanvasSizeLabel(size), + const canvasSizes = (data.canvasSizes || []).filter((size) => { + const enabled = size.enabled !== 0 && size.enabled !== false; + return enabled || (playlistCanvasId && Number(size.id) === playlistCanvasId); + }).map((size) => Object.assign({}, size, { + label: formatCanvasSizeLabel(size) + (size.enabled === 0 || size.enabled === false ? ' (Disabled)' : ''), canvasId: Number(size.id) || null, selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id)) })); diff --git a/src/web/views/signage/canvas-sizes/list.hbs b/src/web/views/signage/canvas-sizes/list.hbs index 4ec1cec..49f6155 100644 --- a/src/web/views/signage/canvas-sizes/list.hbs +++ b/src/web/views/signage/canvas-sizes/list.hbs @@ -24,6 +24,7 @@