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'
- ? '
' +
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, /