Add onboarding weather and template gradients
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-28 20:05:23 +01:00
parent aa07a78912
commit 3960931ebe
80 changed files with 12750 additions and 519 deletions
+28 -1
View File
@@ -77,6 +77,28 @@
});
}
function updateDataSourceToggle(form, response) {
var toggleButton = document.querySelector('button[form="' + form.id + '"][data-async-data-source-toggle]');
if (!toggleButton) {
return;
}
var willEnable = toggleButton.getAttribute('data-enabled') !== 'true';
toggleButton.setAttribute('data-enabled', willEnable ? 'true' : 'false');
toggleButton.className = toggleButton.className.replace(/btn-(danger|success)/g, willEnable ? 'btn-danger' : 'btn-success');
toggleButton.innerHTML = '<i class="bi ' + (willEnable ? 'bi-pause-fill' : 'bi-play-fill') + ' me-1" aria-hidden="true"></i>' + (willEnable ? 'Disable' : 'Enable');
var message = '';
try {
message = new URL(response.url, window.location.href).searchParams.get('message') || '';
} catch (_error) {
message = '';
}
if (typeof showToast === 'function') {
showToast(message || (willEnable ? 'Data source enabled.' : 'Data source disabled.'), 'success');
}
}
function initDeleteButtons() {
document.addEventListener('click', function (event) {
var deleteButton = event.target.closest('[data-delete-action-url]');
@@ -610,7 +632,7 @@
} catch (_error) {
actionPath = String(form.action || '');
}
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath) || /^\/data-sources\/(?:api-sources|rss-feeds|weather)\/\d+$/.test(actionPath);
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath);
event.preventDefault();
@@ -641,6 +663,11 @@
return;
}
if (/^\/data-sources\/(?:api-sources|rss-feeds|weather)\/\d+$/.test(actionPath)) {
updateDataSourceToggle(form, response);
return;
}
if (shouldReloadAfterSuccess && response && response.ok) {
window.location.reload();
return;
+88 -12
View File
@@ -7,7 +7,9 @@
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var LIST_PAGE_SIZE = 25;
var CLIENT_ROW_REMOVAL_DELAY_MS = 1000;
var latestDashboardState = null;
var pendingClientRowRemovals = {};
function getClientSearchInput() {
var table = document.getElementById('dashboard-clients-table');
@@ -276,6 +278,21 @@
return compareClientSortValues(leftValue, rightValue);
}
function compareClientNames(leftValue, rightValue) {
var leftName = String(leftValue || '').trim();
var rightName = String(rightValue || '').trim();
if (!leftName && !rightName) {
return 0;
}
if (!leftName) {
return 1;
}
if (!rightName) {
return -1;
}
return leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
}
var sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
@@ -283,7 +300,9 @@
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (var index = 0; index < sortKeys.length; index += 1) {
var sortKeyName = sortKeys[index];
var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
var comparison = sortKeyName === 'client'
? compareClientNames(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient))
: compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
@@ -336,7 +355,7 @@
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientIdInput: document.querySelector('[data-client-move-client-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
@@ -350,7 +369,7 @@
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
@@ -367,8 +386,8 @@
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
if (elements.clientIdInput) {
elements.clientIdInput.value = clientId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
@@ -687,11 +706,6 @@
&& typeof tbody.removeChild === 'function'
&& typeof document.createElement === 'function';
if (!visibleClients.length) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
return;
}
if (!canPatchRows) {
tbody.innerHTML = visibleClients.map(function (client) {
return renderClientRow(client, hasActionsColumn);
@@ -704,8 +718,32 @@
existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row;
});
var visibleRowKeys = {};
visibleClients.forEach(function (client) {
visibleRowKeys[String(getClientRowKey(client) || '').trim()] = true;
});
function getClientIdentity(client) {
return [
String(client && (client.client_name || client.name || '') || '').trim().toLowerCase(),
String(client && (client.player_url || client.playerPublicBaseUrl || '') || '').trim().replace(/\/$/, '').toLowerCase()
].join('|');
}
var visibleClientIdentities = {};
visibleClients.forEach(function (client) {
var identity = getClientIdentity(client);
if (identity !== '|') {
visibleClientIdentities[identity] = true;
}
});
var nextRows = visibleClients.map(function (client) {
var rowKey = String(getClientRowKey(client) || '').trim();
if (pendingClientRowRemovals[rowKey]) {
clearTimeout(pendingClientRowRemovals[rowKey]);
delete pendingClientRowRemovals[rowKey];
}
var row = existingRows[rowKey] || null;
if (!row) {
@@ -719,6 +757,44 @@
return Boolean(row);
});
Object.keys(existingRows).forEach(function (rowKey) {
if (visibleRowKeys[rowKey]) {
return;
}
var existingRow = existingRows[rowKey];
var existingNameCell = existingRow.querySelector('td[data-label="Client"] > div');
var existingIdentity = [
String(existingNameCell && existingNameCell.textContent || '').trim().toLowerCase(),
String(existingRow.getAttribute('data-client-player-base-url') || '').trim().replace(/\/$/, '').toLowerCase()
].join('|');
if (existingIdentity !== '|' && visibleClientIdentities[existingIdentity]) {
if (pendingClientRowRemovals[rowKey]) {
clearTimeout(pendingClientRowRemovals[rowKey]);
delete pendingClientRowRemovals[rowKey];
}
if (existingRow.parentNode === tbody) {
tbody.removeChild(existingRow);
}
return;
}
if (pendingClientRowRemovals[rowKey]) {
return;
}
pendingClientRowRemovals[rowKey] = setTimeout(function () {
delete pendingClientRowRemovals[rowKey];
var row = existingRows[rowKey];
if (row && row.parentNode === tbody && !visibleRowKeys[rowKey]) {
tbody.removeChild(row);
}
if (!tbody.querySelector('tr[data-client-key]')) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
}
}, CLIENT_ROW_REMOVAL_DELAY_MS);
});
nextRows.forEach(function (row, index) {
var referenceNode = tbody.children[index] || null;
if (referenceNode !== row) {
@@ -726,8 +802,8 @@
}
});
while (tbody.children.length > nextRows.length) {
tbody.removeChild(tbody.lastElementChild);
if (!visibleClients.length && !tbody.querySelector('tr[data-client-key]')) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
}
}
+431
View File
@@ -0,0 +1,431 @@
(function () {
var form = document.getElementById('onboarding-pair-form');
var pairingCard = document.getElementById('onboarding-pair-card');
var pairingProgress = document.getElementById('onboarding-pair-progress');
var message = document.getElementById('onboarding-pair-message');
var codeInputsContainer = document.getElementById('onboarding-pair-code-inputs');
var codeField = document.getElementById('onboarding-pair-code');
var clientIdField = document.getElementById('onboarding-pair-client-id');
var anotherButton = document.getElementById('onboarding-pair-another');
var anotherButtonLabel = document.getElementById('onboarding-pair-another-label');
var manualButton = document.getElementById('onboarding-pair-manual');
var connectButton = document.getElementById('onboarding-pair-connect');
var clientListButton = document.getElementById('onboarding-pair-client-list');
var scanner = document.getElementById('onboarding-scanner');
var scannerVideo = document.getElementById('onboarding-scanner-video');
var scannerCapture = document.getElementById('onboarding-scanner-capture');
var scannerClose = document.getElementById('onboarding-scanner-close');
var scannerMessage = document.getElementById('onboarding-scanner-message');
var scannerDebugOutput = document.getElementById('onboarding-scanner-debug');
var scannerDebug = new URLSearchParams(window.location.search).get('scanner-debug') === '1';
if (!form || !message) {
return;
}
function getClientId() {
var storageKey = 'pulse-signage-player-client-id';
var clientId = clientIdField ? String(clientIdField.value || '').trim() : '';
if (!clientId) {
try { clientId = String(window.sessionStorage.getItem(storageKey) || '').trim(); } catch (_error) {}
}
if (!clientId) {
clientId = window.crypto && typeof window.crypto.randomUUID === 'function'
? window.crypto.randomUUID()
: 'client-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12);
try { window.sessionStorage.setItem(storageKey, clientId); } catch (_error) {}
}
if (clientIdField) { clientIdField.value = clientId; }
return clientId;
}
getClientId();
function traceScanner(value) {
if (!scannerDebug || !scannerDebugOutput) { return; }
scannerDebugOutput.classList.remove('d-none');
var lines = (scannerDebugOutput.textContent ? scannerDebugOutput.textContent.split('\n') : []).filter(Boolean);
lines.push(new Date().toISOString().slice(11, 19) + ' ' + value);
scannerDebugOutput.textContent = lines.slice(-8).join('\n');
}
var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : [];
function normalizeCode(value) {
return String(value || '').replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, codeInputs.length);
}
function syncCodeField() {
if (codeField) {
codeField.value = codeInputs.map(function (input) { return input.value; }).join('').toUpperCase();
}
}
function setCode(value, startIndex) {
var normalized = normalizeCode(value);
var index = startIndex || 0;
normalized.split('').forEach(function (character) {
if (index < codeInputs.length) {
codeInputs[index].value = character;
index += 1;
}
});
syncCodeField();
if (index < codeInputs.length) {
codeInputs[index].focus();
} else if (codeInputs.length) {
codeInputs[codeInputs.length - 1].focus();
}
}
var scannerStream = null;
var scannerFrame = null;
var scannerLastDecodeAt = 0;
var scannerCanvas = document.createElement('canvas');
var scannerWorkCanvas = document.createElement('canvas');
var scannerDecodeCanvas = document.createElement('canvas');
function decodeQrCanvas(canvas) {
if (typeof window.jsQR !== 'function') { return null; }
var workCanvas = canvas;
var maximumDimension = 1024;
if (Math.max(canvas.width, canvas.height) > maximumDimension) {
var scale = maximumDimension / Math.max(canvas.width, canvas.height);
scannerWorkCanvas.width = Math.round(canvas.width * scale);
scannerWorkCanvas.height = Math.round(canvas.height * scale);
scannerWorkCanvas.getContext('2d').drawImage(canvas, 0, 0, scannerWorkCanvas.width, scannerWorkCanvas.height);
workCanvas = scannerWorkCanvas;
}
var context = workCanvas.getContext('2d', { willReadFrequently: true });
var imageData = context.getImageData(0, 0, workCanvas.width, workCanvas.height);
var result = window.jsQR(imageData.data, workCanvas.width, workCanvas.height, { inversionAttempts: 'attemptBoth' });
if (result) { return result; }
scannerDecodeCanvas.width = workCanvas.width;
scannerDecodeCanvas.height = workCanvas.height;
var decodeContext = scannerDecodeCanvas.getContext('2d', { willReadFrequently: true });
var source = new Uint8ClampedArray(imageData.data);
var cleaned = new Uint8ClampedArray(source.length);
for (var cleanY = 0; cleanY < workCanvas.height; cleanY += 1) {
for (var cleanX = 0; cleanX < workCanvas.width; cleanX += 1) {
var cleanSamples = [];
for (var cleanOffsetY = -1; cleanOffsetY <= 1; cleanOffsetY += 1) {
for (var cleanOffsetX = -1; cleanOffsetX <= 1; cleanOffsetX += 1) {
var cleanSampleX = Math.max(0, Math.min(workCanvas.width - 1, cleanX + cleanOffsetX));
var cleanSampleY = Math.max(0, Math.min(workCanvas.height - 1, cleanY + cleanOffsetY));
cleanSamples.push(source[(cleanSampleY * workCanvas.width + cleanSampleX) * 4]);
}
}
cleanSamples.sort(function (left, right) { return left - right; });
var cleanIndex = (cleanY * workCanvas.width + cleanX) * 4;
cleaned[cleanIndex] = cleaned[cleanIndex + 1] = cleaned[cleanIndex + 2] = cleanSamples[4];
cleaned[cleanIndex + 3] = 255;
}
}
source = cleaned;
var expanded = decodeContext.createImageData(workCanvas.width, workCanvas.height);
var radius = Math.max(3, Math.round(workCanvas.width / 205));
for (var y = 0; y < workCanvas.height; y += 1) {
for (var x = 0; x < workCanvas.width; x += 1) {
var samples = [];
for (var offsetY = -radius; offsetY <= radius; offsetY += 2) {
for (var offsetX = -radius; offsetX <= radius; offsetX += 2) {
var sampleX = Math.max(0, Math.min(workCanvas.width - 1, x + offsetX));
var sampleY = Math.max(0, Math.min(workCanvas.height - 1, y + offsetY));
samples.push(source[(sampleY * workCanvas.width + sampleX) * 4]);
}
}
var maximum = Math.max.apply(null, samples);
var index = (y * workCanvas.width + x) * 4;
expanded.data[index] = maximum;
expanded.data[index + 1] = maximum;
expanded.data[index + 2] = maximum;
expanded.data[index + 3] = 255;
}
}
decodeContext.putImageData(expanded, 0, 0);
return window.jsQR(expanded.data, workCanvas.width, workCanvas.height, { inversionAttempts: 'attemptBoth' });
}
function closeScanner() {
if (scannerFrame) { window.cancelAnimationFrame(scannerFrame); scannerFrame = null; }
if (scannerStream) {
scannerStream.getTracks().forEach(function (track) { track.stop(); });
scannerStream = null;
}
if (scannerVideo) {
scannerVideo.srcObject = null;
scannerVideo.classList.remove('d-none');
}
if (scannerCapture) { scannerCapture.value = ''; }
if (scanner) {
scanner.classList.add('d-none');
scanner.setAttribute('aria-hidden', 'true');
}
}
function resetPairingForm() {
form.reset();
codeInputsContainer.setAttribute('data-pairing-code', '');
codeInputs.forEach(function (input) { input.value = ''; input.disabled = false; });
if (codeField) { codeField.value = ''; }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = false; }
});
if (connectButton) { connectButton.classList.remove('d-none'); }
if (clientListButton) { clientListButton.classList.add('d-none'); }
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
message.className = 'alert d-none';
message.textContent = '';
if (anotherButton) {
anotherButton.classList.remove('btn-secondary');
anotherButton.classList.add('btn-outline-secondary');
anotherButton.setAttribute('aria-label', 'Scan player QR code');
anotherButton.setAttribute('title', 'Scan player QR code');
}
if (anotherButtonLabel) { anotherButtonLabel.classList.add('d-none'); }
if (manualButton) { manualButton.classList.add('d-none'); }
}
function codeFromScan(rawValue) {
try {
var scannedUrl = new URL(String(rawValue || ''), window.location.origin);
if (scannedUrl.pathname === '/pairing') {
return normalizeCode(scannedUrl.searchParams.get('code'));
}
} catch (_error) {}
return normalizeCode(rawValue);
}
function applyScannedCode(rawValue) {
var code = codeFromScan(rawValue);
if (code.length !== codeInputs.length) {
scannerMessage.textContent = 'That QR code is not a Pulse Signage pairing code.';
return false;
}
closeScanner();
resetPairingForm();
setCode(code, 0);
return true;
}
function scanFrame() {
if (!scannerVideo || !scannerStream) { return; }
if (Date.now() - scannerLastDecodeAt < 400) {
scannerFrame = window.requestAnimationFrame(scanFrame);
return;
}
scannerLastDecodeAt = Date.now();
if (!window.BarcodeDetector && typeof window.jsQR === 'function') {
var width = scannerVideo.videoWidth;
var height = scannerVideo.videoHeight;
if (width && height) {
traceScanner('frame ' + width + 'x' + height);
scannerCanvas.width = width;
scannerCanvas.height = height;
var context = scannerCanvas.getContext('2d', { willReadFrequently: true });
context.drawImage(scannerVideo, 0, 0, width, height);
var result = decodeQrCanvas(scannerCanvas);
traceScanner(result ? 'decoded' : 'no QR');
if (result && applyScannedCode(result.data)) { return; }
scannerFrame = window.requestAnimationFrame(scanFrame);
return;
}
scannerFrame = window.requestAnimationFrame(scanFrame);
return;
}
scannerVideo.__pairingBarcodeDetector.detect(scannerVideo).then(function (barcodes) {
if (barcodes.length) {
if (applyScannedCode(barcodes[0].rawValue)) { return; }
}
scannerFrame = window.requestAnimationFrame(scanFrame);
}).catch(function () {
scannerFrame = window.requestAnimationFrame(scanFrame);
});
}
function openScanner() {
if (!scanner || !scannerVideo || (!window.BarcodeDetector && typeof window.jsQR !== 'function')) {
message.className = 'alert alert-warning';
message.textContent = 'Camera scanning is not supported by this browser.';
return;
}
scannerMessage.textContent = 'Point your camera at the player QR code.';
traceScanner('open BarcodeDetector=' + Boolean(window.BarcodeDetector) + ' jsQR=' + (typeof window.jsQR === 'function'));
scannerVideo.classList.remove('d-none');
scanner.classList.remove('d-none');
scanner.setAttribute('aria-hidden', 'false');
if (window.BarcodeDetector) {
scannerVideo.__pairingBarcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] });
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
traceScanner('getUserMedia unavailable');
scannerVideo.classList.add('d-none');
scannerMessage.textContent = 'Use the camera to capture the player QR code.';
if (scannerCapture) { scannerCapture.click(); }
return;
}
navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
focusMode: { ideal: 'continuous' }
},
audio: false
}).then(function (stream) {
scannerStream = stream;
traceScanner('stream opened');
traceScanner(JSON.stringify(stream.getVideoTracks()[0].getSettings ? stream.getVideoTracks()[0].getSettings() : {}));
scannerVideo.srcObject = stream;
return scannerVideo.play();
}).then(function () {
scannerFrame = window.requestAnimationFrame(scanFrame);
}).catch(function () {
traceScanner('stream failed');
scannerVideo.classList.add('d-none');
scannerMessage.textContent = 'Use the camera to capture the player QR code.';
if (scannerCapture) { scannerCapture.click(); }
});
}
if (codeInputs.length) {
setCode(codeInputsContainer.getAttribute('data-pairing-code'), 0);
codeInputs.forEach(function (input, index) {
input.addEventListener('input', function () {
var value = normalizeCode(input.value);
input.value = value.slice(0, 1);
if (value.length > 1) {
setCode(value, index);
} else {
syncCodeField();
if (input.value && index < codeInputs.length - 1) {
codeInputs[index + 1].focus();
}
}
});
input.addEventListener('paste', function (event) {
event.preventDefault();
setCode(event.clipboardData ? event.clipboardData.getData('text') : '', index);
});
input.addEventListener('keydown', function (event) {
if (event.key === 'Backspace' && !input.value && index > 0) {
codeInputs[index - 1].focus();
codeInputs[index - 1].value = '';
syncCodeField();
} else if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault();
codeInputs[index - 1].focus();
} else if (event.key === 'ArrowRight' && index < codeInputs.length - 1) {
event.preventDefault();
codeInputs[index + 1].focus();
}
});
});
}
form.addEventListener('submit', function (event) {
event.preventDefault();
getClientId();
syncCodeField();
if (!codeField || codeField.value.length !== codeInputs.length) {
message.className = 'alert alert-warning';
message.textContent = 'Enter the complete six-character pairing code.';
var firstEmptyInput = codeInputs.filter(function (input) { return !input.value; })[0] || codeInputs[0];
if (firstEmptyInput) { firstEmptyInput.focus(); }
return;
}
message.className = 'alert alert-info';
message.textContent = 'Pairing player...';
var pairingBody = new URLSearchParams(new FormData(form)).toString();
if (pairingCard) { pairingCard.classList.add('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.remove('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
});
fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: pairingBody
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.');
}
if (payload && payload.queued) {
message.className = 'alert alert-info';
message.textContent = 'Pairing is still being completed. Keep this page open and wait for confirmation.';
return;
}
message.className = 'alert alert-success';
message.textContent = 'Pairing saved. The player is loading your screen.';
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
});
if (connectButton) { connectButton.classList.add('d-none'); }
if (clientListButton) { clientListButton.classList.remove('d-none'); }
if (anotherButton) {
anotherButton.classList.remove('d-none');
anotherButton.classList.remove('btn-outline-secondary');
anotherButton.classList.add('btn-secondary');
anotherButton.setAttribute('aria-label', 'Pair another screen');
anotherButton.setAttribute('title', 'Pair another screen');
}
if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); }
if (manualButton) { manualButton.classList.remove('d-none'); }
});
}).catch(function (error) {
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = false; }
});
message.className = 'alert alert-danger';
message.textContent = error && error.message ? error.message : 'Unable to pair player.';
});
});
if (anotherButton) { anotherButton.addEventListener('click', openScanner); }
if (manualButton) {
manualButton.addEventListener('click', function () {
resetPairingForm();
if (codeInputs[0]) { codeInputs[0].focus(); }
});
}
if (scannerClose) { scannerClose.addEventListener('click', closeScanner); }
if (scannerCapture) {
scannerCapture.addEventListener('change', function () {
var file = scannerCapture.files && scannerCapture.files[0];
if (!file) { return; }
scannerMessage.textContent = 'Reading QR code...';
var image = new Image();
image.onload = function () {
if (window.BarcodeDetector) {
new window.BarcodeDetector({ formats: ['qr_code'] }).detect(image).then(function (barcodes) {
if (!barcodes.length || !applyScannedCode(barcodes[0].rawValue)) {
scannerMessage.textContent = 'Could not find a Pulse Signage QR code in that image.';
}
}).catch(function () {
scannerMessage.textContent = 'Could not read that QR image. Try again.';
});
return;
}
try {
var canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
var context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
var result = decodeQrCanvas(canvas);
if (!result || !applyScannedCode(result.data)) {
scannerMessage.textContent = 'Could not find a Pulse Signage QR code in that image.';
}
} catch (_error) {
scannerMessage.textContent = 'Could not read that QR image. Try again.';
}
};
image.onerror = function () {
scannerMessage.textContent = 'Could not read that QR image. Try again.';
};
var reader = new FileReader();
reader.onload = function () { image.src = reader.result; };
reader.onerror = function () { scannerMessage.textContent = 'Could not read that QR image. Try again.'; };
reader.readAsDataURL(file);
});
}
if (scanner) {
scanner.addEventListener('click', function (event) {
if (event.target === scanner) { closeScanner(); }
});
}
}());
+18 -3
View File
@@ -87,7 +87,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
}
function applyBackdropStyle(element, backgroundColor, backgroundImagePath) {
function applyBackdropStyle(element, backgroundColor, backgroundImagePath, backgroundGradient) {
if (!element) {
return;
}
@@ -107,7 +107,21 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var color = String(backgroundColor || '#111111').trim() || '#111111';
element.style.backgroundColor = color;
element.style.backgroundImage = 'none';
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
var angle = Number(gradientData.angle);
gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
}
}
} catch (_error) {
gradient = '';
}
element.style.backgroundImage = gradient || 'none';
element.style.backgroundPosition = 'center';
element.style.backgroundSize = '100% 100%';
element.style.backgroundRepeat = 'no-repeat';
@@ -471,6 +485,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
canvasHeight: currentPreviewCanvasHeight || Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)),
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
fontStylesheetHref: fontStylesheetHref
};
}
@@ -591,7 +606,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
currentPreviewCanvasWidth = canvasWidth;
currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path);
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) {
slidePreviewEmpty.style.display = 'flex';
@@ -33,6 +33,18 @@
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundGradientEnabled = document.getElementById('background-gradient-enabled');
var backgroundGradientInput = document.getElementById('background-gradient');
var backgroundGradientOptions = document.getElementById('background-gradient-options');
var backgroundGradientAngle = document.getElementById('background-gradient-angle');
var backgroundGradientAngleOutput = document.getElementById('background-gradient-angle-output');
var backgroundGradientBar = document.getElementById('background-gradient-bar');
var backgroundGradientBarHandles = document.getElementById('background-gradient-bar-handles');
var backgroundGradientStops = document.getElementById('background-gradient-stops');
var backgroundGradientAddStop = document.getElementById('background-gradient-add-stop');
var draggedGradientStopIndex = -1;
var draggedGradientStopRow = null;
var draggedGradientStopHandle = null;
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
@@ -1117,6 +1129,65 @@
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
if (backgroundGradientEnabled && backgroundGradientEnabled.checked) {
var gradientStops = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (stop) {
return { color: stop.querySelector('[data-gradient-stop-color]').value, position: Number(stop.querySelector('[data-gradient-stop-position]').value || 0) };
});
var gradient = { type: 'linear', stops: gradientStops, angle: Number(backgroundGradientAngle.value || 90) };
backgroundGradientInput.value = JSON.stringify(gradient);
stage.style.backgroundImage = 'linear-gradient(' + gradient.angle + 'deg,' + gradient.stops.map(function (stop) { return stop.color + ' ' + stop.position + '%'; }).join(',') + ')';
var gradientBarTrack = backgroundGradientBar && backgroundGradientBar.querySelector('.gradient-stop-bar-track');
if (gradientBarTrack) gradientBarTrack.style.background = stage.style.backgroundImage;
backgroundGradientOptions.hidden = false;
} else {
backgroundGradientInput.value = '';
stage.style.backgroundImage = 'none';
var emptyGradientBarTrack = backgroundGradientBar && backgroundGradientBar.querySelector('.gradient-stop-bar-track');
if (emptyGradientBarTrack) emptyGradientBarTrack.style.background = 'var(--bs-secondary-bg)';
backgroundGradientOptions.hidden = true;
}
if (backgroundGradientAngleOutput) backgroundGradientAngleOutput.value = String(backgroundGradientAngle.value || 90);
}
function renderBackgroundGradientStops(stops) {
if (!backgroundGradientStops) return;
var normalizedStops = Array.isArray(stops) && stops.length >= 2 ? stops : [{ color: '#111111', position: 0 }, { color: '#334455', position: 100 }];
if (backgroundGradientBarHandles) {
backgroundGradientBarHandles.innerHTML = normalizedStops.map(function (stop, index) {
return '<button type="button" class="gradient-stop-handle" data-gradient-bar-index="' + index + '" style="left:' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%;background:' + escapeHtml(stop.color || '#111111') + ';" aria-label="Gradient stop ' + (index + 1) + '"></button>';
}).join('');
if (backgroundGradientBar) backgroundGradientBar.style.background = 'transparent';
}
backgroundGradientStops.innerHTML = normalizedStops.map(function (stop) {
return '<div class="d-flex align-items-end gap-2" data-gradient-stop><label class="flex-grow-1">Colour<input type="color" class="form-control form-control-color w-100" data-gradient-stop-color value="' + escapeHtml(stop.color || '#111111') + '" /></label><label style="width:6rem">Position<input type="number" class="form-control" data-gradient-stop-position min="0" max="100" value="' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '" /></label><button type="button" class="btn btn-outline-danger" data-gradient-stop-remove aria-label="Remove colour stop">&times;</button></div>';
}).join('');
updateGradientStopRemoveButtons();
}
function updateGradientStopRemoveButtons() {
if (!backgroundGradientStops) return;
var stops = backgroundGradientStops.querySelectorAll('[data-gradient-stop]');
Array.prototype.forEach.call(stops, function (stop) { stop.querySelector('[data-gradient-stop-remove]').disabled = stops.length <= 2; });
}
function syncBackgroundGradientBar() {
if (!backgroundGradientStops || !backgroundGradientBarHandles) return;
var rows = backgroundGradientStops.querySelectorAll('[data-gradient-stop]');
if (backgroundGradientBarHandles.children.length !== rows.length) {
backgroundGradientBarHandles.innerHTML = Array.prototype.map.call(rows, function (row, index) {
var color = row.querySelector('[data-gradient-stop-color]').value || '#111111';
var position = Math.max(0, Math.min(100, Number(row.querySelector('[data-gradient-stop-position]').value) || 0));
return '<button type="button" class="gradient-stop-handle" data-gradient-bar-index="' + index + '" style="left:' + position + '%;background:' + escapeHtml(color) + ';" aria-label="Gradient stop ' + (index + 1) + '"></button>';
}).join('');
return;
}
Array.prototype.forEach.call(rows, function (row, index) {
var handle = backgroundGradientBarHandles.children[index];
if (!handle) return;
handle.style.background = row.querySelector('[data-gradient-stop-color]').value || '#111111';
handle.style.left = Math.max(0, Math.min(100, Number(row.querySelector('[data-gradient-stop-position]').value) || 0)) + '%';
handle.setAttribute('data-gradient-bar-index', index);
});
}
function updateRegionLockBadge(card) {
@@ -1757,6 +1828,79 @@
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
if (backgroundGradientEnabled) backgroundGradientEnabled.addEventListener('change', updateStageBackgroundColor);
if (backgroundGradientAddStop) backgroundGradientAddStop.addEventListener('click', function () {
var stops = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (stop) {
return { color: stop.querySelector('[data-gradient-stop-color]').value, position: Number(stop.querySelector('[data-gradient-stop-position]').value || 0) };
});
stops.push({ color: '#ffffff', position: 100 });
renderBackgroundGradientStops(stops);
updateStageBackgroundColor();
});
if (backgroundGradientStops) backgroundGradientStops.addEventListener('input', function () {
syncBackgroundGradientBar();
updateStageBackgroundColor();
});
if (backgroundGradientStops) backgroundGradientStops.addEventListener('click', function (event) {
var removeButton = event.target.closest('[data-gradient-stop-remove]');
if (!removeButton || removeButton.disabled) return;
removeButton.closest('[data-gradient-stop]').remove();
updateGradientStopRemoveButtons();
syncBackgroundGradientBar();
updateStageBackgroundColor();
});
if (backgroundGradientBar) backgroundGradientBar.addEventListener('click', function (event) {
if (event.target.closest('[data-gradient-bar-index]')) return;
var rect = backgroundGradientBar.getBoundingClientRect();
var position = Math.max(0, Math.min(100, Math.round(((event.clientX - rect.left) / rect.width) * 100)));
var stops = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (stop) { return { color: stop.querySelector('[data-gradient-stop-color]').value, position: Number(stop.querySelector('[data-gradient-stop-position]').value || 0) }; });
stops.push({ color: '#ffffff', position: position });
stops.sort(function (left, right) { return left.position - right.position; });
renderBackgroundGradientStops(stops);
updateStageBackgroundColor();
});
if (backgroundGradientBarHandles) backgroundGradientBarHandles.addEventListener('pointerdown', function (event) {
var handle = event.target.closest('[data-gradient-bar-index]');
if (!handle) return;
draggedGradientStopIndex = Number(handle.getAttribute('data-gradient-bar-index'));
draggedGradientStopRow = backgroundGradientStops.querySelectorAll('[data-gradient-stop]')[draggedGradientStopIndex] || null;
draggedGradientStopHandle = handle;
handle.classList.add('is-dragging');
handle.setPointerCapture(event.pointerId);
event.preventDefault();
});
document.addEventListener('pointermove', function (event) {
if (draggedGradientStopIndex < 0) return;
var rect = backgroundGradientBar.getBoundingClientRect();
var position = Math.max(0, Math.min(100, Math.round(((event.clientX - rect.left) / rect.width) * 100)));
if (draggedGradientStopRow) draggedGradientStopRow.querySelector('[data-gradient-stop-position]').value = position;
if (draggedGradientStopHandle) draggedGradientStopHandle.style.left = position + '%';
var entries = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (row, index) {
return { row: row, handle: backgroundGradientBarHandles.children[index], position: Number(row.querySelector('[data-gradient-stop-position]').value || 0), index: index };
});
entries.sort(function (left, right) { return left.position - right.position || left.index - right.index; });
entries.forEach(function (entry, index) {
backgroundGradientStops.appendChild(entry.row);
backgroundGradientBarHandles.appendChild(entry.handle);
entry.handle.setAttribute('data-gradient-bar-index', index);
});
draggedGradientStopIndex = entries.findIndex(function (entry) { return entry.row === draggedGradientStopRow; });
updateStageBackgroundColor();
});
document.addEventListener('pointerup', function () {
if (draggedGradientStopHandle) draggedGradientStopHandle.classList.remove('is-dragging');
draggedGradientStopIndex = -1;
draggedGradientStopRow = null;
draggedGradientStopHandle = null;
});
document.addEventListener('pointercancel', function () {
draggedGradientStopIndex = -1;
draggedGradientStopRow = null;
draggedGradientStopHandle = null;
});
[backgroundGradientAngle].forEach(function (input) {
if (input) input.addEventListener('input', updateStageBackgroundColor);
});
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
@@ -1858,6 +2002,19 @@
});
}
if (backgroundGradientInput && backgroundGradientInput.value) {
try {
var initialGradient = JSON.parse(backgroundGradientInput.value);
if (initialGradient) {
var initialStops = Array.isArray(initialGradient.stops) ? initialGradient.stops : (initialGradient.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
renderBackgroundGradientStops(initialStops);
if (backgroundGradientAngle && Number.isFinite(Number(initialGradient.angle))) backgroundGradientAngle.value = String(Math.max(0, Math.min(360, Number(initialGradient.angle))));
}
} catch (_error) {
backgroundGradientInput.value = '';
}
}
if (backgroundGradientStops && !backgroundGradientStops.children.length) renderBackgroundGradientStops();
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
+1 -6
View File
@@ -133,12 +133,7 @@
}
function getClientRowKey(client) {
var clientName = String(client && client.client_name ? client.client_name : '').trim();
if (clientName) {
return clientName;
}
return String(client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : '').trim();
}
function getClientDisplayName(client) {