diff --git a/CHANGELOG.md b/CHANGELOG.md index 64302d3..d98801b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## 2.15.5 - 2026-10-17 + +### Added + +- Added an All timetable display mode that includes past items while respecting date filters and the item limit. + +### Changed + +- Renamed the Both timetable display mode to Current & Upcoming without changing existing selections. + +### Fixed + +- Browser requests now show the shared formatted error page for plain-text HTTP errors. +- Template region position and stacking fields now update the preview immediately. +- Region inputs no longer submit the template form when Enter is pressed. +- Region width and height can be cleared and retyped without minimum-size clamping until the field is committed. +- Restored remove controls when selecting image or video media from the Media Library. + ## 2.15.4 - 2026-10-16 ### Added diff --git a/build/package.player.json b/build/package.player.json index ee6cd63..fffda15 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.15.4", + "version": "2.15.5", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index e7da516..4960165 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.15.4", + "version": "2.15.5", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 689bda5..42cb27d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.15.4", + "version": "2.15.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.15.4", + "version": "2.15.5", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 924f774..b2eadeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.15.4", + "version": "2.15.5", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/timetables.js b/src/data/timetables.js index 1103144..65649af 100644 --- a/src/data/timetables.js +++ b/src/data/timetables.js @@ -24,7 +24,7 @@ function normalizeTimeZone(value, fallback) { function normalizeDisplayMode(value) { const mode = String(value || 'upcoming').trim().toLowerCase(); - if (mode === 'current' || mode === 'both') { + if (mode === 'current' || mode === 'both' || mode === 'all') { return mode; } return 'upcoming'; diff --git a/src/player/regions/timetable.js b/src/player/regions/timetable.js index 3b76143..ffd35cb 100644 --- a/src/player/regions/timetable.js +++ b/src/player/regions/timetable.js @@ -80,6 +80,9 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups, config) { var mode = String(displayMode || 'upcoming').trim().toLowerCase(); entries = entries.filter(function (entry) { + if (mode === 'all') { + return true; + } if (mode === 'current') { return isLive(entry, now); } diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index f229327..a0cf079 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -10,6 +10,7 @@ const { renderEditorJsContent, sanitizeFontFamily, sanitizeFontSize, + sanitizeRichText, sanitizeTextColor } = require('#src/player/render-helpers'); const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth'); @@ -21,6 +22,27 @@ const placeholderUtils = (() => { return sandbox.window.placeholderUtils || {}; })(); +const timetableScript = new vm.Script(fs.readFileSync(require.resolve('#src/player/regions/timetable'), 'utf8')); + +function buildTimetableRegionMarkup(region, regionContent, options) { + let timetableRenderer; + const sandbox = { + window: { + placeholderUtils: placeholderUtils, + pulsePlayerRegionTypes: { + register(_type, renderer) { timetableRenderer = renderer; } + } + }, + initialData: { timetableGroups: Array.isArray(options && options.timetableGroups) ? options.timetableGroups : [] }, + escapeHtml: escapeHtml, + sanitizeRichText: sanitizeRichText + }; + timetableScript.runInNewContext(sandbox); + return timetableRenderer.renderRegion(region, regionContent) + .replace('class="template-region timetable"', 'class="slide-preview-region slide-preview-timetable-region"') + .replace('class="template-region-text-scale"', 'class="slide-preview-text-content"'); +} + const SYSTEM_CHROMIUM_PATHS = [ process.env.PUPPETEER_EXECUTABLE_PATH, process.env.CHROMIUM_PATH, @@ -233,6 +255,10 @@ function buildRegionInnerHtml(region, regionContent, baseUrl, options) { const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase(); const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : ''); + if (regionType === 'timetable') { + return buildTimetableRegionMarkup(region, regionContent, options); + } + if (regionType === 'image') { const src = resolveAssetUrl(baseUrl, rawValue); return src diff --git a/src/web/middleware/error-page.js b/src/web/middleware/error-page.js index eefa826..c3b6378 100644 --- a/src/web/middleware/error-page.js +++ b/src/web/middleware/error-page.js @@ -25,9 +25,10 @@ module.exports = function registerErrorPageMiddleware(app, pages) { res.send = function (body) { const statusCode = Number(res.statusCode) || 200; - const isHtml = typeof body === 'string' && /^\s*= 400 && statusCode < 600 && !isHtml && wantsHtmlErrorPage(req, res)) { + if (statusCode >= 400 && statusCode < 600 && isStringBody && !isHtml && wantsHtmlErrorPage(req, res)) { return send.call(res, createErrorPage(pages, req, statusCode, body)); } diff --git a/src/web/public/js/media-picker.js b/src/web/public/js/media-picker.js index 08725b0..407d522 100644 --- a/src/web/public/js/media-picker.js +++ b/src/web/public/js/media-picker.js @@ -188,9 +188,17 @@ var previewBox = region.querySelector('.slide-image-region-preview-box'); if (previewBox) { var safePath = escapeHtml(path); - previewBox.innerHTML = type === 'video' - ? '
' - : '
Selected media preview
'; + var isVideo = type === 'video'; + var isQrImage = String(hidden.name || '').indexOf('existing_region_qr_image_') === 0; + var mediaLabel = isVideo ? 'video' : isQrImage ? 'QR image' : 'image'; + var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image'; + var regionId = escapeHtml(region.getAttribute('data-region-id') || ''); + previewBox.innerHTML = '
' + + (isVideo + ? '' + : 'Selected ' + mediaLabel + ' preview') + + '' + + '
'; } return; } diff --git a/src/web/public/js/regions/type/timetable.js b/src/web/public/js/regions/type/timetable.js index 7a5f8f1..8653fe9 100644 --- a/src/web/public/js/regions/type/timetable.js +++ b/src/web/public/js/regions/type/timetable.js @@ -139,6 +139,9 @@ var mode = String(displayMode || 'upcoming').trim().toLowerCase(); entries = entries.filter(function (entry) { + if (mode === 'all') { + return true; + } if (mode === 'current') { return isLive(entry, now); } @@ -337,7 +340,8 @@ '' + '' + '
' + diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js index 2c1fb8e..03f1067 100644 --- a/src/web/public/js/templates/template-designer.js +++ b/src/web/public/js/templates/template-designer.js @@ -1232,8 +1232,18 @@ return Boolean(getRegionLockRatio(card)); } - function syncLockedDimensions(card, changedField) { - normalizeRegionCard(card, changedField); + function syncLockedDimensions(card, changedField, isEditing) { + if (isEditing) { + var current = readCard(card); + var aspect = parseLockRatio(current.lock_ratio); + if (aspect && changedField === 'width' && current.width > 0) { + writeCard(card, { height: Math.max(12, Math.round(current.width / aspect.ratio)) }); + } else if (aspect && changedField === 'height' && current.height > 0) { + writeCard(card, { width: Math.max(12, Math.round(current.height * aspect.ratio)) }); + } + } else { + normalizeRegionCard(card, changedField); + } updateRegionLockBadge(card); markTemplateFormDirty(); requestOverlayRender(); @@ -1327,9 +1337,6 @@ card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim(); setRegionRemovalState(card); var nameInput = card.querySelector('[name="region_name[]"]'); - var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]'); - var widthInput = card.querySelector('[name="region_width[]"]'); - var heightInput = card.querySelector('[name="region_height[]"]'); var animationPresetInputs = card.querySelectorAll('[data-animation-preset]'); var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]'); var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]'); @@ -1365,21 +1372,24 @@ } }); } - if (lockRatioInput) { - lockRatioInput.addEventListener('input', function () { - syncLockedDimensions(card, 'width'); + ['x', 'y', 'z', 'width', 'height', 'lock_ratio'].forEach(function (field) { + var input = card.querySelector('[name="region_' + field + '[]"]'); + if (!input) { + return; + } + var changedField = field === 'lock_ratio' ? 'width' : field; + input.addEventListener('input', function () { + syncLockedDimensions(card, changedField, true); }); - } - if (widthInput) { - widthInput.addEventListener('input', function () { - syncLockedDimensions(card, 'width'); + input.addEventListener('change', function () { + syncLockedDimensions(card, changedField); }); - } - if (heightInput) { - heightInput.addEventListener('input', function () { - syncLockedDimensions(card, 'height'); - }); - } + }); + card.addEventListener('keydown', function (event) { + if (event.key === 'Enter' && event.target && event.target.tagName === 'INPUT') { + event.preventDefault(); + } + }); card.addEventListener('click', function (event) { if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) { return; diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index 0852c21..7471581 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -81,6 +81,7 @@ module.exports = function registerSlidesRoutes(app, deps) { const apiSources = (apiData.apiSources || []).map(function (source) { return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null }); }); + const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] }; const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] }; const weatherLocations = (weatherData.weatherLocations || []).map(function (location) { const snapshot = common.parseJsonSafe ? common.parseJsonSafe(location.last_response_json) : null; @@ -136,6 +137,7 @@ module.exports = function registerSlidesRoutes(app, deps) { fontStylesheetHref: getFontStylesheetHref(mediaDir), weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir), apiSources: apiSources, + timetableGroups: timetableData.timetableGroups, getItem: getItem, getCachedImagePath: getCachedImagePath }); diff --git a/test/error-page-middleware.test.js b/test/error-page-middleware.test.js new file mode 100644 index 0000000..2e8a2d0 --- /dev/null +++ b/test/error-page-middleware.test.js @@ -0,0 +1,104 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const registerErrorPageMiddleware = require('../src/web/middleware/error-page'); + +function setupMiddleware(request) { + let middleware = null; + const pages = { + renderErrorPage(options) { + return 'formatted ' + options.statusCode + ': ' + options.message + ''; + } + }; + registerErrorPageMiddleware({ + use(value) { + middleware = value; + } + }, pages); + + const response = { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + get() { + return ''; + }, + send(body) { + this.body = body; + return this; + }, + sendStatus(code) { + this.statusCode = code; + this.body = 'status ' + code; + return this; + } + }; + + middleware(request, response, function () {}); + return response; +} + +test('plain browser error responses use the shared formatted page', () => { + const response = setupMiddleware({ + originalUrl: '/slides/180/edit', + currentUser: { id: 1 }, + accepts() { return 'html'; }, + get() { return ''; } + }); + + response.status(404).send('Slide not found'); + + assert.match(response.body, /^formatted 404: Slide not found<\/html>$/); +}); + +test('API errors retain their response body', () => { + const response = setupMiddleware({ + originalUrl: '/api/slides/180', + accepts() { return 'html'; }, + get() { return ''; } + }); + + response.status(404).send('Slide not found'); + + assert.equal(response.body, 'Slide not found'); +}); + +test('object error responses retain their JSON payload', () => { + const response = setupMiddleware({ + originalUrl: '/admin/action', + accepts() { return 'html'; }, + get() { return ''; } + }); + const payload = { error: 'Request failed' }; + + response.status(400).send(payload); + + assert.equal(response.body, payload); +}); + +test('already-rendered HTML errors are left intact', () => { + const response = setupMiddleware({ + originalUrl: '/reset-password', + accepts() { return 'html'; }, + get() { return ''; } + }); + + response.status(400).send('custom form error'); + + assert.equal(response.body, 'custom form error'); +}); + +test('sendStatus errors use the shared formatted page for browser requests', () => { + const response = setupMiddleware({ + originalUrl: '/missing', + accepts() { return 'html'; }, + get() { return ''; } + }); + + response.sendStatus(404); + + assert.match(response.body, /^formatted 404:/); +}); \ No newline at end of file diff --git a/test/slide-form-editor.test.js b/test/slide-form-editor.test.js index 48924c6..4dc6940 100644 --- a/test/slide-form-editor.test.js +++ b/test/slide-form-editor.test.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8'); const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8'); +const mediaPickerSource = fs.readFileSync(require.resolve('../src/web/public/js/media-picker.js'), 'utf8'); const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8'); const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8'); const slideHtmlRegionSource = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/html.js'), 'utf8'); @@ -73,6 +74,12 @@ test('slide form queues editor image cleanup on save and close', () => { assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())")); }); +test('media library selections keep image and video remove controls', () => { + assert.ok(mediaPickerSource.includes("var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';")); + assert.ok(mediaPickerSource.includes('data-remove-\' + removeAction')); + assert.ok(mediaPickerSource.includes('slide-image-region-preview-remove')); +}); + test('slide thumbnail previews treat image-only text as visible content', () => { assert.ok(slideThumbnailPreviewSource.includes('/ { assert.ok(script.includes('regionNameValidationToastShown = false;')); }); +function createRegionCardHarness(lockRatio = '') { + const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8'); + const fields = {}; + const cardListeners = {}; + const initialValues = { name: 'Region', type: 'text', key: 'Region', label: 'Region', x: '80', y: '80', z: '1', width: '300', height: '120', lock_ratio: lockRatio, animation_json: '{}' }; + for (const [field, value] of Object.entries(initialValues)) { + fields[`[name="region_${field}[]"]`] = { + value, + listeners: {}, + addEventListener(type, listener) { this.listeners[type] = listener; } + }; + } + fields['[data-region-remove-button]'] = { addEventListener() {} }; + const card = { + dataset: {}, + querySelector(selector) { return fields[selector] || null; }, + querySelectorAll() { return []; }, + addEventListener(type, listener) { cardListeners[type] = listener; } + }; + const previews = []; + let dirtyCount = 0; + const context = vm.createContext({ + window: {}, + regionCardTemplate: { content: { firstElementChild: { cloneNode: () => card } } }, + populateRegionCard() {}, + setRegionRemovalState() {}, + getRegionName: () => 'Region', + updateRegionLockBadge() {}, + markTemplateFormDirty() { dirtyCount += 1; }, + requestOverlayRender() { previews.push(context.readCard(card)); }, + getCanvasSize: () => ({ width: 1920, height: 1080 }), + normalizeLockRatio: (value) => value + }); + vm.runInContext(fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8'), context); + Object.assign(context, { + readCard: context.window.templateDesignerUtils.readCard, + writeCard: context.window.templateDesignerUtils.writeCard, + parseLockRatio: context.window.templateDesignerUtils.parseLockRatio, + clamp: context.window.templateDesignerUtils.clamp + }); + for (const name of ['fitRegionToCanvas', 'normalizeRegionCard', 'syncLockedDimensions', 'makeRegionCard']) { + const match = script.match(new RegExp(' function ' + name + '\\([^]*?\\n }')); + assert.ok(match, `Missing designer function: ${name}`); + vm.runInContext(match[0], context); + } + context.makeRegionCard({ region_key: 'Region' }); + return { + previews, + get dirtyCount() { return dirtyCount; }, + edit(field, value, type = 'input') { + const input = fields[`[name="region_${field}[]"]`]; + input.value = value; + input.listeners[type](); + }, + value(field) { return Number(fields[`[name="region_${field}[]"]`].value); }, + rawValue(field) { return fields[`[name="region_${field}[]"]`].value; }, + keydown: (event) => cardListeners.keydown(event) + }; +} + +test('region coordinate and stacking fields immediately refresh the preview and mark it dirty', () => { + const harness = createRegionCardHarness(); + harness.edit('x', '140'); + harness.edit('y', '220'); + harness.edit('z', '3'); + assert.equal(harness.previews.length, 3); + assert.equal(harness.previews.at(-1).x, 140); + assert.equal(harness.previews.at(-1).y, 220); + assert.equal(harness.previews.at(-1).z_index, 3); + assert.equal(harness.dirtyCount, 3); +}); + +test('region dimensions allow clearing and typing below the minimum until committed', () => { + for (const field of ['width', 'height']) { + const harness = createRegionCardHarness(); + for (const value of ['', '1', '10', '100']) { + harness.edit(field, value); + assert.equal(harness.rawValue(field), value); + } + harness.edit(field, '1', 'change'); + assert.equal(harness.value(field), 12); + harness.edit(field, '9999', 'change'); + assert.equal(harness.value(field), field === 'width' ? 1920 : 1080); + } +}); + +test('locked region dimensions update their counterpart without replacing partial input', () => { + const harness = createRegionCardHarness('2:1'); + harness.edit('width', ''); + assert.equal(harness.rawValue('width'), ''); + harness.edit('width', '1'); + assert.equal(harness.rawValue('width'), '1'); + harness.edit('width', '400'); + assert.equal(harness.value('height'), 200); + harness.edit('height', '150'); + assert.equal(harness.value('width'), 300); + harness.edit('height', '150', 'change'); + assert.equal(harness.value('height'), 150); + assert.equal(harness.value('width'), 300); +}); + +test('Enter in region inputs prevents implicit saves without blocking buttons or textarea newlines', () => { + const harness = createRegionCardHarness(); + for (const [key, tagName, expected] of [['Enter', 'INPUT', true], ['Tab', 'INPUT', false], ['Enter', 'BUTTON', false], ['Enter', 'TEXTAREA', false]]) { + let prevented = false; + harness.keydown({ key, target: { tagName }, preventDefault() { prevented = true; } }); + assert.equal(prevented, expected); + } +}); + test('normalizeBackgroundGradient accepts linear colors and clamps the angle', () => { assert.equal(normalizeBackgroundGradient(JSON.stringify({ type: 'radial', diff --git a/test/timetable-region-timezone.test.js b/test/timetable-region-timezone.test.js index 6f5cb16..e555f45 100644 --- a/test/timetable-region-timezone.test.js +++ b/test/timetable-region-timezone.test.js @@ -130,6 +130,34 @@ test('timetable date ranges agree in preview and player, before limiting items', const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' }; assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/); assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/); + + groups[0].entries = [ + { id: 5, title: 'Future', start_datetime: '2099-08-20T10:00:00Z' }, + groups[0].entries[0], + { id: 6, title: 'Past', start_datetime: '2000-08-20T10:00:00Z', end_datetime: '2000-08-20T11:00:00Z' } + ]; + const displayCases = [ + [{ display_mode: 'upcoming' }, ['Future']], + [{ display_mode: 'current' }, ['Live']], + [{ display_mode: 'both' }, ['Live', 'Future']], + [{ display_mode: 'all' }, ['Past', 'Live', 'Future']], + [{ display_mode: 'all', max_items: 1 }, ['Past']], + [{ display_mode: 'all', date_filter: 'before', date_to: '2001-01-01T00:00:00Z' }, ['Past']], + [{ display_mode: 'all', date_filter: 'after', date_from: '2099-01-01T00:00:00Z' }, ['Future']], + [{ display_mode: 'all', date_filter: 'between', date_from: '2000-01-01T00:00:00Z', date_to: '2001-01-01T00:00:00Z' }, ['Past']] + ]; + for (const [settings, titles] of displayCases) { + const content = { value: '{{title}}', timetable_group_id: 1, max_items: 5, ...settings }; + const outputs = [previewModule.renderPreview({}, content, { timetableGroups: groups }), playerModule.renderRegion({}, content)]; + for (const output of outputs) { + for (const title of ['Past', 'Live', 'Future']) { + assert.equal(output.includes(title), titles.includes(title), JSON.stringify(settings)); + } + for (let index = 1; index < titles.length; index++) { + assert.ok(output.indexOf(titles[index - 1]) < output.indexOf(titles[index])); + } + } + } }); test('timetable date controls default to all and pass unsaved date ranges to preview', () => { @@ -158,6 +186,9 @@ test('timetable date controls default to all and pass unsaved date ranges to pre assert.equal(context.date_from, ''); assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value); assert.doesNotMatch(html, /Use the selected group and display mode/); + assert.match(html, /