From a45552fed34775bd0fb1a92c7b14f54a7525a9e2 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Mon, 3 Aug 2026 21:20:54 +0100 Subject: [PATCH] Release v2.5.4 --- CHANGELOG.md | 11 ++ package-lock.json | 11 +- package.json | 3 +- src/data/admin.js | 3 +- src/data/qr-code.js | 36 ++++++ src/data/slides.js | 25 +++- src/player/public/css/player.css | 8 ++ src/player/regions/qr-code.js | 16 +++ src/web/lib/media/slide-thumbnails.js | 7 ++ src/web/middleware.js | 3 + .../public/js/data-sources/api-source-form.js | 28 +++++ src/web/public/js/regions/type/qr-code.js | 118 ++++++++++++++++++ src/web/public/js/slides/slide-form.js | 2 + src/web/routes/signage/dashboard/index.js | 11 ++ .../routes/signage/slides/form-view-model.js | 2 +- .../signage/templates/form-view-model.js | 2 +- .../views/data-sources/api-sources/form.hbs | 7 +- src/web/views/signage/dashboard/index.hbs | 7 ++ src/web/views/signage/slides/form.hbs | 1 + src/web/views/signage/templates/form.hbs | 2 + test/qr-code.test.js | 52 ++++++++ test/web-dashboard-page.test.js | 25 ++++ 22 files changed, 371 insertions(+), 9 deletions(-) create mode 100644 src/data/qr-code.js create mode 100644 src/player/regions/qr-code.js create mode 100644 src/web/public/js/regions/type/qr-code.js create mode 100644 test/qr-code.test.js create mode 100644 test/web-dashboard-page.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f125f5..637454b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## 2.5.4 - 2026-08-03 + +### Changed + +- The API source bearer token field now uses a password input with a right-side toggle to show or hide the token. + +### Fixed + +- QR code regions now render at the correct size in the slide editor preview, and the QR chip header now matches the other region cards. +- The dashboard onboarding link now uses the player public base URL stored in the database instead of a `/screen/...` URL. + ## 2.5.3 - 2026-08-03 ### Changed diff --git a/package-lock.json b/package-lock.json index 221cc66..0307413 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.5.1", + "version": "2.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.5.1", + "version": "2.5.4", "dependencies": { "@sparticuz/chromium": "^137.0.0", "animate.css": "^4.1.1", @@ -20,6 +20,7 @@ "mysql2": "^3.14.3", "puppeteer-core": "^24.16.0", "qrcode": "^1.5.4", + "qrcode-generator": "^2.0.4", "sharp": "^0.35.3", "ws": "^8.21.0" }, @@ -2708,6 +2709,12 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", diff --git a/package.json b/package.json index 169cd26..53e25a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.5.3", + "version": "2.5.4", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { @@ -29,6 +29,7 @@ "mysql2": "^3.14.3", "puppeteer-core": "^24.16.0", "qrcode": "^1.5.4", + "qrcode-generator": "^2.0.4", "sharp": "^0.35.3", "ws": "^8.21.0" }, diff --git a/src/data/admin.js b/src/data/admin.js index 2f5a8f1..5282d00 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -22,9 +22,10 @@ async function fetchAdminData(pool) { ORDER BY s.id DESC `); const [screens] = await pool.query(` - SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name + SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url FROM d_screens s LEFT JOIN c_playlists p ON p.id = s.playlist_id + LEFT JOIN d_players pl ON pl.device_id = s.player_id ORDER BY s.id DESC `); const [playlistSlides] = await pool.query(` diff --git a/src/data/qr-code.js b/src/data/qr-code.js new file mode 100644 index 0000000..5fefedb --- /dev/null +++ b/src/data/qr-code.js @@ -0,0 +1,36 @@ +const QRCode = require('qrcode'); + +async function createQrCodeSvg(value) { + const text = String(value === undefined || value === null ? '' : value).trim(); + if (!text) { + return ''; + } + + return QRCode.toString(text, { + type: 'svg', + margin: 1, + errorCorrectionLevel: 'M' + }); +} + +async function buildQrCodeContent(value) { + const text = String(value === undefined || value === null ? '' : value).trim(); + const content = { + type: 'qr-code', + value: text + }; + + if (text) { + const svg = await createQrCodeSvg(text); + if (svg) { + content.qr_svg = svg; + } + } + + return content; +} + +module.exports = { + createQrCodeSvg, + buildQrCodeContent +}; \ No newline at end of file diff --git a/src/data/slides.js b/src/data/slides.js index b6ff2fd..daab94f 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -2,6 +2,7 @@ const { fetchTemplateById } = require('./templates'); const { parseJsonSafe } = require('./utils'); +const { buildQrCodeContent } = require('./qr-code'); const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li']; const DEFAULT_FONT_SIZE = 32; @@ -142,6 +143,14 @@ function buildTemplateContent(template, body, filesByField, existingContent) { type: 'webpage', value: submitted === undefined ? current : String(submitted || '').trim() }; + } else if (region.region_type === 'qr-code') { + const submitted = body[`region_qr_code_${region.id}`]; + const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; + const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim(); + content[region.region_key] = { + type: 'qr-code', + value: nextValue + }; } else if (region.region_type === 'rtmp') { const submitted = body[`region_rtmp_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; @@ -202,7 +211,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) { font_size: style.font_size, font_color: style.font_color }; - } else if (!['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) { + } else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) { const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; const suffix = '_' + region.id; const generic = {}; @@ -273,10 +282,22 @@ async function buildSlidePayload(pool, req, existingSlide) { } if (template) { + const content = buildTemplateContent(template, req.body, filesByField, existingContent); + await Promise.all(Object.keys(content).map(async function (regionKey) { + const region = template.regions.find(function (item) { + return String(item.region_key || '').trim() === regionKey; + }); + if (!region || region.region_type !== 'qr-code') { + return; + } + + content[regionKey] = await buildQrCodeContent(content[regionKey].value); + })); + return { title, templateId: template.id, - contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)) + contentJson: JSON.stringify(content) }; } diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index 68c4034..872ab37 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -443,6 +443,14 @@ body.screen-blackout #app { overflow: hidden; } +.template-region.qr-code img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: #fff; +} + .template-region.html iframe { width: 100%; height: 100%; diff --git a/src/player/regions/qr-code.js b/src/player/regions/qr-code.js new file mode 100644 index 0000000..d692b87 --- /dev/null +++ b/src/player/regions/qr-code.js @@ -0,0 +1,16 @@ +// QR code region rendering for embedded URL-to-image output. + +var registry = window.pulsePlayerRegionTypes; + +function renderQrCodeRegion(region, regionContent) { + var svg = String(regionContent && regionContent.qr_svg || '').trim(); + if (!svg) { + return ''; + } + + return '
QR code
'; +} + +registry.register('qr-code', { + renderRegion: renderQrCodeRegion +}); \ No newline at end of file diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index 8cee565..6a4f8a2 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -114,6 +114,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) { : ''; } + if (regionType === 'qr-code') { + const src = String(regionContent.qr_svg || rawValue || regionContent.qr_code || '').trim(); + return src + ? '' + escapeHtml(region.label || region.region_key || 'qr code') + '' + : ''; + } + if (regionType === 'html') { const html = String(rawValue || '').trim(); return html diff --git a/src/web/middleware.js b/src/web/middleware.js index eff0b12..fd819bd 100644 --- a/src/web/middleware.js +++ b/src/web/middleware.js @@ -2,6 +2,7 @@ const express = require('express'); const fs = require('fs'); +const path = require('path'); module.exports = function registerMiddleware(app, deps) { const pool = deps.pool; @@ -11,10 +12,12 @@ module.exports = function registerMiddleware(app, deps) { const uploadsDir = deps.UPLOADS_DIR; const thumbnailsDir = deps.THUMBNAILS_DIR; const assetDir = deps.ASSET_DIR; + const qrcodeGeneratorDir = path.join(__dirname, '..', '..', 'node_modules', 'qrcode-generator', 'dist'); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/assets', express.static(assetDir)); + app.use('/assets/vendor/qrcode-generator', express.static(qrcodeGeneratorDir)); app.use('/media', express.static(mediaDir)); fs.mkdirSync(uploadsDir, { recursive: true }); fs.mkdirSync(thumbnailsDir, { recursive: true }); diff --git a/src/web/public/js/data-sources/api-source-form.js b/src/web/public/js/data-sources/api-source-form.js index 22a2a1a..396449e 100644 --- a/src/web/public/js/data-sources/api-source-form.js +++ b/src/web/public/js/data-sources/api-source-form.js @@ -9,6 +9,29 @@ var methodSelect = form.querySelector('[data-api-source-auth-method]'); var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]'); var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]')); + var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]'); + var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]'); + + function updateBearerTokenToggle() { + if (!bearerTokenInput || !bearerTokenToggle) { + return; + } + + var isVisible = bearerTokenInput.type === 'text'; + bearerTokenToggle.setAttribute('aria-pressed', String(isVisible)); + bearerTokenToggle.setAttribute('aria-label', isVisible ? 'Hide bearer token' : 'Show bearer token'); + bearerTokenToggle.innerHTML = ''; + } + + function toggleBearerTokenVisibility() { + if (!bearerTokenInput) { + return; + } + + bearerTokenInput.type = bearerTokenInput.type === 'password' ? 'text' : 'password'; + updateBearerTokenToggle(); + bearerTokenInput.focus(); + } function updatePanels() { var method = String(methodSelect && methodSelect.value || 'none').trim(); @@ -28,5 +51,10 @@ methodSelect.addEventListener('change', updatePanels); } + if (bearerTokenToggle && bearerTokenInput) { + bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility); + updateBearerTokenToggle(); + } + updatePanels(); }()); \ No newline at end of file diff --git a/src/web/public/js/regions/type/qr-code.js b/src/web/public/js/regions/type/qr-code.js new file mode 100644 index 0000000..bea0f48 --- /dev/null +++ b/src/web/public/js/regions/type/qr-code.js @@ -0,0 +1,118 @@ +// QR code region helpers for editor previews and defaults. + +(function () { + var registry = window.pulseRegionTypes; + var utils = window.pulseRegionUtils || {}; + + function escapeHtml(value) { + return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); + } + + function buildQrCodeUrl(value) { + var text = String(value && typeof value === 'object' ? (value.value !== undefined ? value.value : value.qr_code) : value || '').trim(); + var svg = String(value && typeof value === 'object' && value.qr_svg ? value.qr_svg : '').trim(); + if (svg) { + return svg; + } + + if (!text || typeof window.qrcode !== 'function') { + return ''; + } + + try { + var code = window.qrcode(0, 'M'); + code.addData(text); + code.make(); + svg = code.createSvgTag(4, 1); + return svg || ''; + } catch (_error) { + return ''; + } + } + + function normalizeQrSvg(svg) { + var markup = String(svg || '').trim(); + if (!markup || markup.charAt(0) !== '<') { + return markup; + } + + return markup.replace(/^]*)>/i, function (_match, attrText) { + var attrs = String(attrText || ''); + if (!/\bwidth\s*=\s*/i.test(attrs)) { + attrs += ' width="95%"'; + } + if (!/\bheight\s*=\s*/i.test(attrs)) { + attrs += ' height="95%"'; + } + if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) { + attrs += ' preserveAspectRatio="xMidYMid meet"'; + } + if (!/\bstyle\s*=\s*/i.test(attrs)) { + attrs += ' style="display:block;width:95%;height:95%;"'; + } + return ''; + }); + } + + function renderPreview(region, value) { + var src = buildQrCodeUrl(value); + if (!src) { + return '
QR Code
'; + } + + if (String(src || '').trim().charAt(0) === '<') { + return '
' + normalizeQrSvg(src) + '
'; + } + + return 'QR code preview'; + } + + function renderEditorCard(context) { + var region = context.region; + var current = String(context.current || ''); + return '' + + '
' + + '
' + + '' + escapeHtml(region.label) + '' + + '
' + + 'QR Code' + + '
' + + '
' + + '
' + + '' + + '' + + '
The QR code is generated from the URL above.
' + + '
' + + '
'; + } + + function buildEditorCardContext(context, existingContent) { + var existing = existingContent && context && context.region && existingContent[context.region.region_key] ? existingContent[context.region.region_key] : null; + return { + region: context.region, + current: String(context.current || (existing && (existing.value !== undefined ? existing.value : existing.qr_code)) || ''), + qr_svg: existing && existing.qr_svg ? existing.qr_svg : '' + }; + } + + function buildPreviewRenderContext(region, card, existingContent) { + var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; + var input = card && card.querySelector ? card.querySelector('input[type="url"][name="region_qr_code_' + region.id + '"]') : null; + + return { + value: input ? input.value : (current && current.value !== undefined ? current.value : (current && current.qr_code !== undefined ? current.qr_code : '')), + existingContent: existingContent || {} + }; + } + + registry.register('qr-code', { + label: 'QR Code', + getDefaultRegionSize: function () { + return { width: 240, height: 240 }; + }, + renderPreview: renderPreview, + renderEditorCard: renderEditorCard, + buildEditorCardContext: buildEditorCardContext, + buildPreviewRenderContext: buildPreviewRenderContext + }); +}()); \ No newline at end of file diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 1b4d0a5..2604ce6 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -592,6 +592,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev ? ' slide-preview-video-region' : region.region_type === 'webpage' ? ' slide-preview-webpage-region' + : region.region_type === 'qr-code' + ? ' slide-preview-qr-code-region' : region.region_type === 'rtmp' ? ' slide-preview-rtmp-region' : region.region_type === 'rss' diff --git a/src/web/routes/signage/dashboard/index.js b/src/web/routes/signage/dashboard/index.js index c91d4ff..8c77819 100644 --- a/src/web/routes/signage/dashboard/index.js +++ b/src/web/routes/signage/dashboard/index.js @@ -2,7 +2,17 @@ const { renderView } = require('../../../view'); +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/$/, ''); +} + module.exports = function renderDashboardPage(data, message, currentUser) { + const primaryPlayerUrl = Array.isArray(data.screens) + ? String((data.screens.find(function (screen) { + return screen && String(screen.public_base_url || '').trim(); + }) || {}).public_base_url || '').trim() + : ''; + return renderView('dashboard/index', { title: 'Dashboard', active: 'dashboard', @@ -13,6 +23,7 @@ module.exports = function renderDashboardPage(data, message, currentUser) { clients: data.clients || [], slides: data.slides || [], connectedClientsCount: Number(data.connectedClientsCount || 0), + primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null, scripts: ['js/dashboard/dashboard-page.js'] }); }; diff --git a/src/web/routes/signage/slides/form-view-model.js b/src/web/routes/signage/slides/form-view-model.js index 61f392d..95fda83 100644 --- a/src/web/routes/signage/slides/form-view-model.js +++ b/src/web/routes/signage/slides/form-view-model.js @@ -53,7 +53,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) { existingContent: viewSlide && viewSlide.content ? viewSlide.content : {} }, assetVersion: assetVersion, - slideEditorScripts: getRegionEditorScripts(assetVersion), + slideEditorScripts: ['vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)), currentUser: currentUser || null }; } diff --git a/src/web/routes/signage/templates/form-view-model.js b/src/web/routes/signage/templates/form-view-model.js index e076e6f..b8ed466 100644 --- a/src/web/routes/signage/templates/form-view-model.js +++ b/src/web/routes/signage/templates/form-view-model.js @@ -63,7 +63,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '', canvasSizes: canvasSizes || [], animationPresets: animationPresets, - scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) + scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) }; } diff --git a/src/web/views/data-sources/api-sources/form.hbs b/src/web/views/data-sources/api-sources/form.hbs index 7641b40..3ab60c5 100644 --- a/src/web/views/data-sources/api-sources/form.hbs +++ b/src/web/views/data-sources/api-sources/form.hbs @@ -59,7 +59,12 @@
{{#if (hasPermission currentUser "playlists.read")}} diff --git a/src/web/views/signage/slides/form.hbs b/src/web/views/signage/slides/form.hbs index 7324f5c..906974e 100644 --- a/src/web/views/signage/slides/form.hbs +++ b/src/web/views/signage/slides/form.hbs @@ -120,6 +120,7 @@ + {{#each slideEditorScripts}} {{/each}} diff --git a/src/web/views/signage/templates/form.hbs b/src/web/views/signage/templates/form.hbs index 4bc28f8..d0a9af8 100644 --- a/src/web/views/signage/templates/form.hbs +++ b/src/web/views/signage/templates/form.hbs @@ -125,6 +125,8 @@
{{/if}} + + {{#> modal-shell modalId="region-add-modal" modalLabelId="region-add-modal-label" modalDialogClass="modal-dialog-centered"}}