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 '
 + ')
';
+}
+
+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
+ ? '
'
+ : '';
+ }
+
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(/^