Add onboarding weather and template gradients
This commit is contained in:
@@ -3,7 +3,7 @@ const TASK = {
|
||||
title: 'Onboarding device prune',
|
||||
category: 'cleanup',
|
||||
trigger: 'scheduled recurring task, hourly',
|
||||
purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.',
|
||||
purpose: 'remove unbound onboarding devices that have been idle for more than one minute.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 60 * 60 * 1000
|
||||
};
|
||||
|
||||
@@ -25,7 +25,17 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate) {
|
||||
return (screens || []).flatMap(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return (connectionState.connections || []).map(function (connection) {
|
||||
const seenClientKeys = new Set();
|
||||
return (connectionState.connections || []).filter(function (connection) {
|
||||
const deviceId = String(connection && connection.deviceId || '').trim();
|
||||
const clientId = String(connection && connection.clientId || '').trim();
|
||||
const key = deviceId && clientId ? `${deviceId}:${clientId}` : String(connection && connection.id || '').trim();
|
||||
if (seenClientKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seenClientKeys.add(key);
|
||||
return true;
|
||||
}).map(function (connection) {
|
||||
const deviceId = String(connection.deviceId || '').trim();
|
||||
const playerBaseUrl = normalizePlayerBaseUrl(connection.playerPublicBaseUrl);
|
||||
return Object.assign({}, connection, {
|
||||
|
||||
@@ -186,6 +186,7 @@ function buildThumbnailPreviewPayload(slide, options) {
|
||||
canvasWidth: canvasSize.width,
|
||||
canvasHeight: canvasSize.height,
|
||||
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
|
||||
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
|
||||
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
|
||||
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
|
||||
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
|
||||
|
||||
@@ -311,6 +311,7 @@ function buildThumbnailPreviewPayload(slide, options) {
|
||||
canvasWidth: canvasSize.width,
|
||||
canvasHeight: canvasSize.height,
|
||||
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
|
||||
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
|
||||
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
|
||||
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
|
||||
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options)
|
||||
|
||||
@@ -216,35 +216,51 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
async function forwardAnnouncementRefresh(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
|
||||
body: { command: 'announcement-refresh' }
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
const targetBaseUrls = Array.from(new Set([
|
||||
resolvedPlayerInternalBaseUrl,
|
||||
configuredBridgeInternalBaseUrl
|
||||
].map(normalizeBaseUrl).filter(Boolean)));
|
||||
if (!targetBaseUrls.length) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ command: 'announcement-refresh' })
|
||||
});
|
||||
const results = await Promise.allSettled(targetBaseUrls.map(async function (targetBaseUrl) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
|
||||
body: { command: 'announcement-refresh' }
|
||||
});
|
||||
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ command: 'announcement-refresh' })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
});
|
||||
}));
|
||||
const successfulResults = results.filter(function (result) {
|
||||
return result.status === 'fulfilled';
|
||||
});
|
||||
if (!successfulResults.length) {
|
||||
throw (results[0] && results[0].reason) || new Error(`Unable to refresh announcements for player ${slug}.`);
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
return successfulResults.map(function (result) {
|
||||
return result.value;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -893,6 +893,103 @@
|
||||
background: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.onboarding-pairing-card > .card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.onboarding-pairing-card .card-body {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.onboarding-pairing-card.is-pairing .card-body > :not(.onboarding-pairing-progress) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.onboarding-pairing-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.onboarding-pairing-card > .card-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.onboarding-client-list-link {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-pin-inputs {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.onboarding-pin-inputs .form-control {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
padding: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.onboarding-pin-inputs .form-control + .form-control {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.onboarding-pin-inputs .form-control:first-child {
|
||||
border-radius: var(--bs-border-radius) 0 0 var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.onboarding-pin-inputs .form-control:last-child {
|
||||
border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0;
|
||||
}
|
||||
|
||||
.onboarding-pin-inputs .form-control:focus {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.onboarding-scanner {
|
||||
position: fixed;
|
||||
z-index: 1050;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.68);
|
||||
}
|
||||
|
||||
.onboarding-scanner-panel {
|
||||
width: min(100%, 32rem);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: var(--bs-border-radius-lg);
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: var(--bs-box-shadow-lg);
|
||||
}
|
||||
|
||||
.onboarding-scanner-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--bs-border-radius);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.timetable-entries-table-shell {
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
|
||||
@@ -1114,11 +1211,15 @@
|
||||
|
||||
.screen-command-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr) minmax(12rem, 0.7fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.screen-command-panel-pairing-only {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.screen-command-panel-left {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
@@ -1161,6 +1262,28 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.screen-command-pairing {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
align-content: start;
|
||||
padding: 0.9rem 0 0.9rem 1rem;
|
||||
border-left: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.screen-command-pairing > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.screen-command-pairing .btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.screen-command-panel-pairing-only .screen-command-pairing {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.screen-command-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1206,6 +1329,11 @@
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.screen-command-pairing {
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.screen-command-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
@@ -3330,4 +3458,43 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
|
||||
.weather-preview-hourly-item {
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.gradient-stop-bar {
|
||||
position: relative;
|
||||
height: 2.25rem;
|
||||
padding: 0.45rem 0;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.gradient-stop-bar-track {
|
||||
height: 1.35rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.gradient-stop-bar-handles {
|
||||
position: absolute;
|
||||
inset: 0 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gradient-stop-handle {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
width: 1rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: 2px solid var(--bs-body-bg);
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: 0 0 0 1px var(--bs-body-color);
|
||||
transform: translateX(-50%);
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.gradient-stop-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
}());
|
||||
@@ -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">×</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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+10102
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
// Admin client command routes for connected screens.
|
||||
|
||||
const { commitDeviceBinding } = require('#src/player/onboarding');
|
||||
const { createPageAuthToken } = require('#src/request-auth');
|
||||
|
||||
module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -227,7 +227,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (command === 'setclientname') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
||||
const physicalDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
|
||||
const clientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
|
||||
const deviceId = physicalDeviceId || clientId || String((req.body && req.body.connectionId) || req.query.connectionId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
@@ -236,13 +238,26 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return res.status(400).json({ error: 'Client name is required' });
|
||||
}
|
||||
|
||||
const [currentRows] = await pool.query(
|
||||
let bindingDeviceId = deviceId;
|
||||
let [currentRows] = await pool.query(
|
||||
`SELECT client_name
|
||||
FROM d_onboarding_devices
|
||||
WHERE device_id = ?
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
[clientId || deviceId]
|
||||
);
|
||||
if (!currentRows.length && clientId && physicalDeviceId) {
|
||||
bindingDeviceId = physicalDeviceId;
|
||||
[currentRows] = await pool.query(
|
||||
`SELECT client_name
|
||||
FROM d_onboarding_devices
|
||||
WHERE device_id = ?
|
||||
LIMIT 1`,
|
||||
[physicalDeviceId]
|
||||
);
|
||||
} else if (clientId) {
|
||||
bindingDeviceId = clientId;
|
||||
}
|
||||
const onboardingRow = currentRows[0] || null;
|
||||
const currentName = String(onboardingRow && onboardingRow.client_name ? onboardingRow.client_name : '').trim();
|
||||
if (currentName && currentName.toLowerCase() === clientName.toLowerCase()) {
|
||||
@@ -282,7 +297,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
liveConnections = [];
|
||||
}
|
||||
|
||||
const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
|
||||
const available = await isClientNameAvailable(pool, clientName, bindingDeviceId, liveConnections);
|
||||
if (!available) {
|
||||
return res.status(409).json({ error: 'Client name already exists.' });
|
||||
}
|
||||
@@ -321,11 +336,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
const [updateResult] = await pool.query(
|
||||
`UPDATE d_onboarding_devices pod
|
||||
JOIN d_screens s ON s.id = pod.screen_id
|
||||
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
|
||||
WHERE s.slug = ? AND pod.device_id = ?`,
|
||||
[clientName, slug, deviceId]
|
||||
`UPDATE d_onboarding_devices
|
||||
SET client_name = ?, modified_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?`,
|
||||
[clientName, bindingDeviceId]
|
||||
);
|
||||
if (!updateResult.affectedRows) {
|
||||
return res.status(404).json({ error: 'Client not found' });
|
||||
@@ -344,24 +358,43 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (command === 'moveclient') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
|
||||
const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
|
||||
let physicalPlayerId = '';
|
||||
const tabClientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||
const submittedPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
|
||||
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId) {
|
||||
return res.status(400).json({ error: 'Client connection is required' });
|
||||
}
|
||||
if (!targetScreenSlug) {
|
||||
return res.status(400).json({ error: 'Target screen is required' });
|
||||
}
|
||||
|
||||
if (submittedPlayerBaseUrl) {
|
||||
const [playerRows] = await pool.query(
|
||||
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
|
||||
[submittedPlayerBaseUrl]
|
||||
);
|
||||
const registeredPlayerId = String(playerRows[0] && playerRows[0].identifier || '').trim();
|
||||
if (registeredPlayerId) {
|
||||
physicalPlayerId = registeredPlayerId;
|
||||
}
|
||||
}
|
||||
|
||||
physicalPlayerId = physicalPlayerId || legacyDeviceId;
|
||||
if (!physicalPlayerId) {
|
||||
return res.status(400).json({ error: 'Registered player identity is required' });
|
||||
}
|
||||
|
||||
const [currentRows] = await pool.query(
|
||||
`SELECT d.client_name, s.slug AS current_screen_slug
|
||||
FROM d_onboarding_devices d
|
||||
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||
WHERE d.device_id = ?
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
[physicalPlayerId]
|
||||
);
|
||||
const onboardingRow = currentRows[0] || null;
|
||||
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
|
||||
@@ -371,13 +404,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
return res.status(400).json({ error: 'Client name is required' });
|
||||
}
|
||||
|
||||
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
|
||||
if (!connectionId && currentScreenSlug && currentScreenSlug === targetScreenSlug) {
|
||||
return res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
deviceId: deviceId,
|
||||
deviceId: physicalPlayerId,
|
||||
clientName: resolvedClientName,
|
||||
targetScreenSlug: targetScreenSlug,
|
||||
ok: true,
|
||||
@@ -385,19 +418,78 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof commitDeviceBinding !== 'function') {
|
||||
return res.status(500).json({ error: 'Client binding is unavailable.' });
|
||||
}
|
||||
|
||||
const liveConnections = await fetchScreenConnections(slug);
|
||||
|
||||
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
||||
let status = null;
|
||||
if (onboardingRow) {
|
||||
const [targetRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ? LIMIT 1', [targetScreenSlug]);
|
||||
if (!targetRows.length) {
|
||||
return res.status(404).json({ error: 'Target screen not found' });
|
||||
}
|
||||
if (tabClientId) {
|
||||
const [tabUpdateResult] = await pool.query(
|
||||
`UPDATE d_onboarding_devices
|
||||
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?`,
|
||||
[resolvedClientName, targetRows[0].id, tabClientId]
|
||||
);
|
||||
if (!tabUpdateResult.affectedRows) {
|
||||
await pool.query(
|
||||
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
||||
VALUES (?, ?, ?)`,
|
||||
[tabClientId, resolvedClientName, targetRows[0].id]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await pool.query(
|
||||
`UPDATE d_onboarding_devices
|
||||
SET screen_id = ?, modified_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?`,
|
||||
[targetRows[0].id, physicalPlayerId]
|
||||
);
|
||||
}
|
||||
const stablePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
|
||||
if (stablePlayerBaseUrl && !tabClientId) {
|
||||
const [playerRows] = await pool.query(
|
||||
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
|
||||
[stablePlayerBaseUrl]
|
||||
);
|
||||
const stablePlayer = playerRows[0] || null;
|
||||
if (stablePlayer && stablePlayer.identifier) {
|
||||
await pool.query(
|
||||
`UPDATE d_onboarding_devices
|
||||
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?`,
|
||||
[resolvedClientName, targetRows[0].id, stablePlayer.identifier]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
|
||||
SELECT ?, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
|
||||
)`,
|
||||
[stablePlayer.identifier, resolvedClientName, targetRows[0].id, stablePlayer.identifier]
|
||||
);
|
||||
}
|
||||
}
|
||||
status = {
|
||||
client_name: resolvedClientName,
|
||||
screen_id: targetRows[0].id,
|
||||
screen_slug: targetScreenSlug
|
||||
};
|
||||
} else {
|
||||
status = {
|
||||
client_name: resolvedClientName,
|
||||
screen_id: null,
|
||||
screen_slug: targetScreenSlug,
|
||||
live_only: true
|
||||
};
|
||||
}
|
||||
let targetPlayerUrl = '';
|
||||
const liveConnection = Array.isArray(liveConnections)
|
||||
? liveConnections.find(function (connection) {
|
||||
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
|
||||
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
|
||||
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
|
||||
return candidateConnectionId === connectionId || candidateConnectionId === physicalPlayerId || candidateDeviceId === physicalPlayerId;
|
||||
}) || liveConnections[0] || null
|
||||
: null;
|
||||
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
|
||||
@@ -407,17 +499,24 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
if (!targetPlayerUrl) {
|
||||
targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
}
|
||||
|
||||
const moveToken = createPageAuthToken({
|
||||
scope: 'screen-move',
|
||||
playerId: physicalPlayerId,
|
||||
connectionId: connectionId || null,
|
||||
screenSlug: targetScreenSlug
|
||||
});
|
||||
if (playerBaseUrl) {
|
||||
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined, deviceId || null);
|
||||
url: targetPlayerUrl,
|
||||
moveToken: moveToken || null
|
||||
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
|
||||
} else {
|
||||
await forwardPlayerCommandForConnections(slug, liveConnections, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined, deviceId || null);
|
||||
url: targetPlayerUrl,
|
||||
moveToken: moveToken || null
|
||||
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
|
||||
}
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
@@ -429,7 +528,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
deviceId: deviceId,
|
||||
deviceId: physicalPlayerId,
|
||||
clientName: status ? status.client_name : resolvedClientName,
|
||||
targetScreenSlug: targetScreenSlug,
|
||||
playerUrl: targetPlayerUrl,
|
||||
|
||||
@@ -632,8 +632,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId]
|
||||
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, background_gradient, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, actorId]
|
||||
);
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
@@ -707,8 +707,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||
});
|
||||
await pool.query(
|
||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
|
||||
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, background_gradient = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, template.id]
|
||||
);
|
||||
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../lib/pagination');
|
||||
const renderWeatherLocationsPage = require('./weather/list');
|
||||
const renderWeatherLocationAddPage = require('./weather/add');
|
||||
const renderWeatherLocationEditPage = require('./weather/edit');
|
||||
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('./weather/duplicate');
|
||||
const { fetchAppSettings } = require('../../../data/app-settings');
|
||||
|
||||
async function getWeatherLocationUsageIds(pool, common) {
|
||||
@@ -85,6 +86,26 @@ module.exports = function registerWeatherRoutes(app, deps) {
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get('/data-sources/weather/:id/duplicate', requirePermission('weather.read'), requirePermission('weather.create'), async function (req, res, next) {
|
||||
try {
|
||||
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
||||
if (!location) return res.status(404).send('Weather location not found');
|
||||
|
||||
let duplicateName = buildDuplicateWeatherLocationName(location.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_weather_locations', duplicateName)) {
|
||||
duplicateName = buildDuplicateWeatherLocationName(location.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderWeatherLocationAddPage(buildDuplicateWeatherLocation(location, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
providerAvailability: await getProviderAvailability(),
|
||||
messageVariant: 'info',
|
||||
showSaveSecondaryActions: true
|
||||
}));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.post('/data-sources/weather', requirePermission('weather.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Weather location duplication helpers.
|
||||
|
||||
function buildDuplicateWeatherLocationName(locationName) {
|
||||
return 'Copy of ' + String(locationName || '').trim();
|
||||
}
|
||||
|
||||
function buildDuplicateWeatherLocation(location, duplicateName) {
|
||||
return Object.assign({}, location, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
last_pulled_at: null,
|
||||
last_pull_error: '',
|
||||
last_response_status: null,
|
||||
last_response_content_type: '',
|
||||
last_response_json: ''
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateWeatherLocationName,
|
||||
buildDuplicateWeatherLocation
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
||||
const { renderView } = require('../view');
|
||||
|
||||
module.exports = function registerOnboardingRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/pairing', requirePermission('pairing.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const [screens] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
|
||||
res.send(renderView('onboarding/pair', {
|
||||
title: 'Pair player',
|
||||
active: 'pairing',
|
||||
currentUser: req.currentUser,
|
||||
clientId: String(req.query.clientId || '').trim(),
|
||||
pairingCode: String(req.query.code || '').trim(),
|
||||
screens: screens,
|
||||
scripts: ['vendor/jsqr/jsQR.js', 'js/onboarding/pair.js?v=' + encodeURIComponent(require('#root/package.json').version)]
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/pairing', requirePermission('pairing.allow'), async function (req, res) {
|
||||
const pairingCode = String(req.body && req.body.pairingCode || '').trim();
|
||||
const clientId = String(req.body && req.body.clientId || '').trim();
|
||||
const clientName = String(req.body && req.body.clientName || '').trim();
|
||||
const screenSlug = String(req.body && req.body.screenSlug || '').trim();
|
||||
if (!pairingCode || !clientName || !screenSlug) {
|
||||
return res.status(400).json({ error: 'All pairing fields are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
let targetBaseUrl = playerInternalBaseUrl;
|
||||
let deviceId = '';
|
||||
let resolvedClientId = clientId;
|
||||
{
|
||||
const resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode);
|
||||
const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
|
||||
const resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders)
|
||||
});
|
||||
const resolveBody = await resolveResponse.text();
|
||||
let resolved = null;
|
||||
try { resolved = JSON.parse(resolveBody); } catch (_error) {}
|
||||
if (!resolveResponse.ok || !resolved || !resolved.deviceId) {
|
||||
const registrations = await fetchPlayerRegistrations(pool);
|
||||
const remoteRegistrations = registrations.filter(function (player) {
|
||||
const internalUrl = String(player && player.internal_base_url || '').replace(/\/$/, '');
|
||||
return internalUrl && internalUrl !== playerInternalBaseUrl;
|
||||
});
|
||||
if (!remoteRegistrations.length) {
|
||||
return res.status(resolveResponse.status || 401).type(resolveResponse.headers.get('content-type') || 'application/json').send(resolveBody || JSON.stringify({ error: 'Unable to resolve kiosk pairing code.' }));
|
||||
}
|
||||
for (const remoteRegistration of remoteRegistrations) {
|
||||
targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, '');
|
||||
const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
|
||||
const bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders)
|
||||
});
|
||||
const bridgeResolveBody = await bridgeResolveResponse.text();
|
||||
try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; }
|
||||
if (bridgeResolveResponse.ok && resolved && resolved.deviceId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!resolved || !resolved.deviceId) {
|
||||
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
|
||||
}
|
||||
}
|
||||
if (resolved && resolved.deviceId) {
|
||||
deviceId = String(resolved.deviceId).trim();
|
||||
resolvedClientId = String(resolved.clientId || resolvedClientId || '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedClientId) {
|
||||
return res.status(400).json({ error: 'Client ID is required.' });
|
||||
}
|
||||
const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug };
|
||||
const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload });
|
||||
const response = await fetch(`${targetBaseUrl}/api/onboarding`, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const responseBody = await response.text();
|
||||
res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody);
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -21,11 +21,17 @@ const registerAuditLogRoutes = require('./settings/audit-log');
|
||||
const registerAboutRoutes = require('./settings/about/routes');
|
||||
const registerInternalSyncRoutes = require('./internal/sync');
|
||||
const registerScreensRoutes = require('./signage/screens/routes');
|
||||
const registerOnboardingRoutes = require('./onboarding');
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function registerRoutes(app, deps) {
|
||||
// Group routes by the dependency bundle they need.
|
||||
registerAuthAndAccountRoutes(app, deps);
|
||||
registerOnboardingRoutes(app, {
|
||||
pool: deps.pool,
|
||||
playerInternalBaseUrl: deps.playerInternalBaseUrl,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
registerSignageRoutes(app, deps);
|
||||
registerSettingsAndContentRoutes(app, deps);
|
||||
registerSettingsPageRoutes(app, {
|
||||
|
||||
@@ -32,6 +32,21 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
|
||||
}
|
||||
|
||||
function compareClientNames(leftValue, rightValue) {
|
||||
const leftName = String(leftValue || '').trim();
|
||||
const 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 });
|
||||
}
|
||||
|
||||
const sortKeys = accessors[normalizedSortKey]
|
||||
? [normalizedSortKey]
|
||||
: ['client'];
|
||||
@@ -39,7 +54,9 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
|
||||
for (let index = 0; index < sortKeys.length; index += 1) {
|
||||
const sortKeyName = sortKeys[index];
|
||||
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
|
||||
const 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;
|
||||
|
||||
@@ -2,17 +2,7 @@
|
||||
|
||||
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',
|
||||
@@ -25,7 +15,6 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
slides: data.slides || [],
|
||||
connectedClientsCount: Number(data.connectedClientsCount || 0),
|
||||
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
|
||||
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
|
||||
scripts: ['js/dashboard/dashboard-page.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ function buildDuplicateTemplate(template, duplicateName) {
|
||||
canvas_size_height: source.canvas_size_height,
|
||||
background_color: source.background_color,
|
||||
background_image_path: source.background_image_path,
|
||||
background_gradient: source.background_gradient,
|
||||
region_usage: [],
|
||||
regions: Array.isArray(source.regions) ? source.regions.map(cloneRegion) : []
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center">
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="card card-outline card-secondary mt-3">
|
||||
<div class="card-header d-flex align-items-center flex-wrap"><h3 class="card-title mb-0 flex-grow-1">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0 ms-auto" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary" data-weather-forecast-mode="daily" aria-pressed="true">Daily</button><button type="button" class="btn btn-outline-secondary" data-weather-forecast-mode="hourly" aria-pressed="false">24 hours</button></div></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-3">Daily and hourly forecasts will appear here after the first successful fetch.</div>
|
||||
{{#unless weatherPreview.hasSnapshot}}<div class="small text-body-secondary mb-3">Daily and hourly forecasts will appear here after the first successful fetch.</div>{{/unless}}
|
||||
<div id="weather-daily-forecast" class="weather-daily-forecast">{{#each weatherPreview.forecast}}<div class="border rounded p-2 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div>{{/each}}</div>
|
||||
<div id="weather-hourly-forecast" class="border-top mt-4 pt-3 d-none"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
|
||||
<div id="weather-hourly-forecast"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
|
||||
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
<td data-label="Name">{{name}}</td><td data-label="Location">{{location_label}}<br><small class="text-muted">{{latitude}}, {{longitude}} · {{timezone}}</small></td><td data-label="Provider">{{provider}}</td><td data-label="Refresh interval">{{intervalLabel}}</td><td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
|
||||
<td data-label="Actions"><div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'weather.update')}}<a class="btn btn-sm btn-primary" href="/data-sources/weather/{{id}}/edit">Edit</a>{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'weather.delete')}}<form class="inline-form" method="post" action="/data-sources/weather/{{id}}/delete" data-confirm-message="Delete this weather location?"><button class="btn btn-sm btn-danger" type="submit">Delete</button></form>{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'weather.create')}}<a class="btn btn-sm btn-secondary" href="/data-sources/weather/{{id}}/duplicate">Dupe</a>{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'weather.delete')}}<form class="inline-form" method="post" action="/data-sources/weather/{{id}}/delete" data-confirm-message="Delete this weather location?">{{#if inUse}}<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>{{else}}<button class="btn btn-sm btn-danger" type="submit">Delete</button>{{/if}}</form>{{/if}}
|
||||
</div></td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Weather source</h2>
|
||||
<p>Configure a remote weather feed once, then serve its cached snapshot to every player.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-primary h-100 weather-mock-card">
|
||||
<div class="card-header"><h3 class="card-title">Source configuration</h3></div>
|
||||
<div class="card-body weather-mock-card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="weather-name">Name</label>
|
||||
<input id="weather-name" class="form-control" value="{{weather.name}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label" for="weather-location">Location</label>
|
||||
<input id="weather-location" class="form-control" value="{{weather.location}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-provider">Provider</label>
|
||||
<select id="weather-provider" class="form-select">
|
||||
<option selected>{{weather.provider}} (no key)</option>
|
||||
<option>Custom JSON endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12"><div class="form-text"><i class="bi bi-info-circle me-1"></i>Location is the display name. Coordinates identify the weather point and can be set from a map picker, browser location, or manual entry.</div></div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="weather-latitude">Latitude</label>
|
||||
<input id="weather-latitude" class="form-control" value="{{weather.latitude}}" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="weather-longitude">Longitude</label>
|
||||
<input id="weather-longitude" class="form-control" value="{{weather.longitude}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-temperature-unit">Temperature</label>
|
||||
<select id="weather-temperature-unit" class="form-select"><option selected>{{weather.temperatureUnit}}</option><option>Fahrenheit (°F)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-wind-unit">Wind speed</label>
|
||||
<select id="weather-wind-unit" class="form-select"><option selected>{{weather.windUnit}}</option><option>Miles per hour (mph)</option><option>Metres per second (m/s)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="weather-precipitation-unit">Precipitation</label>
|
||||
<select id="weather-precipitation-unit" class="form-select"><option selected>{{weather.precipitationUnit}}</option><option>Inches (in)</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="weather-refresh">Refresh interval</label>
|
||||
<select id="weather-refresh" class="form-select"><option>15 minutes</option><option selected>{{weather.refreshInterval}}</option><option>1 hour</option></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="weather-forecast-mode">Forecast display</label>
|
||||
<select id="weather-forecast-mode" class="form-select"><option selected value="daily">Daily forecast</option><option value="hourly">Hourly forecast (next 24 hours)</option></select>
|
||||
<div class="form-text">The source can cache both datasets; this controls what the region displays.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer weather-mock-card-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Refresh now</button>
|
||||
<button type="button" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Save source</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-secondary h-100 weather-mock-card">
|
||||
<div class="card-header d-flex align-items-center gap-2"><h3 class="card-title mb-0 flex-grow-1">Cached snapshot</h3><span class="badge text-bg-success flex-shrink-0"><i class="bi bi-check-circle me-1"></i>Last fetch succeeded</span></div>
|
||||
<div class="card-body weather-mock-card-body">
|
||||
<div class="d-flex align-items-start justify-content-between border-bottom pb-3 mb-3">
|
||||
<div><div class="text-body-secondary small">{{weather.location}}</div><div class="display-4 fw-semibold">{{weather.temperature}}°</div><div class="fw-medium">{{weather.condition}}</div></div>
|
||||
<i class="bi bi-cloud-sun weather-mock-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="row g-3 small">
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Feels like</span><strong>{{weather.feelsLike}}°</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Humidity</span><strong>{{weather.humidity}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Wind</span><strong>{{weather.wind}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">UV index</span><strong>{{weather.uvIndex}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Sunrise</span><strong>{{weather.sunrise}}</strong></div>
|
||||
<div class="col-6"><span class="text-body-secondary d-block">Sunset</span><strong>{{weather.sunset}}</strong></div>
|
||||
</div>
|
||||
<div class="alert alert-light border mt-4 mb-0 small"><i class="bi bi-database-check me-1"></i> Cached {{weather.cacheAge}}. Players keep using this snapshot if the next request fails.</div>
|
||||
</div>
|
||||
<div class="card-footer weather-mock-card-footer small text-body-secondary">Last successful fetch: {{weather.fetchedAt}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between gap-2 flex-wrap"><h3 class="card-title mb-0">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary">Daily</button><button type="button" class="btn btn-outline-secondary">24 hours</button></div></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-3">{{weather.forecastModeLabel}} · timezone {{weather.timezone}}</div>
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
{{#each weather.forecast}}
|
||||
<div class="col"><div class="border rounded p-3 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="border-top mt-4 pt-3">
|
||||
<div class="small text-body-secondary mb-2">Hourly option preview · next 24 hours</div>
|
||||
<div class="d-flex gap-2 overflow-auto pb-2">
|
||||
{{#each weather.hourly}}
|
||||
<div class="border rounded p-2 text-center flex-shrink-0" style="width:7.5rem"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.weather-mock-card { display: flex; flex-direction: column; }
|
||||
.weather-mock-card-body { flex: 1 1 auto; }
|
||||
.weather-mock-card-footer { min-height: 3.5rem; }
|
||||
.weather-mock-icon { font-size: 4rem; color: #e0a11a; }
|
||||
.weather-mock-forecast-icon { display: block; font-size: 2rem; color: #e0a11a; margin: 1rem 0 .65rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Connect a screen</h2>
|
||||
<p>Pair a player with a screen using its six-character PIN.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7 col-xl-6">
|
||||
<div class="card card-outline card-primary admin-form-card onboarding-pairing-card" id="onboarding-pair-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Screen setup</h3>
|
||||
</div>
|
||||
<form id="onboarding-pair-form" method="post" action="/pairing" novalidate>
|
||||
<div class="card-body">
|
||||
<div class="onboarding-pairing-progress d-none" id="onboarding-pair-progress" role="status" aria-live="polite">
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||
<span>Completing pairing...</span>
|
||||
</div>
|
||||
<div id="onboarding-pair-message" class="alert d-none" role="alert"></div>
|
||||
<input type="hidden" id="onboarding-pair-client-id" name="clientId" value="{{clientId}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="onboarding-pair-code-1">Player PIN</label>
|
||||
<div class="onboarding-pin-inputs" id="onboarding-pair-code-inputs" data-pairing-code="{{pairingCode}}" role="group" aria-label="Player PIN" aria-required="true">
|
||||
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-1" data-pairing-code-input type="text" maxlength="1" inputmode="text" autocomplete="one-time-code" aria-label="Player PIN character 1">
|
||||
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-2" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 2">
|
||||
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-3" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 3">
|
||||
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-4" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 4">
|
||||
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-5" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 5">
|
||||
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-6" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 6">
|
||||
</div>
|
||||
<input id="onboarding-pair-code" type="hidden" name="pairingCode">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="onboarding-pair-name">Client name</label>
|
||||
<input class="form-control" id="onboarding-pair-name" name="clientName" maxlength="255" required placeholder="Lobby player" autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="onboarding-pair-screen">Screen</label>
|
||||
<select class="form-select" id="onboarding-pair-screen" name="screenSlug" required>
|
||||
<option value="">Select a screen</option>
|
||||
{{#each screens}}
|
||||
<option value="{{slug}}">{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex flex-wrap align-items-center">
|
||||
<button class="btn btn-outline-secondary" id="onboarding-pair-another" type="button" aria-label="Scan player QR code" title="Scan player QR code">
|
||||
<i class="bi bi-camera" aria-hidden="true"></i>
|
||||
<span class="d-none" id="onboarding-pair-another-label">Pair another</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary d-none ms-auto" id="onboarding-pair-manual" type="button">Enter PIN manually</button>
|
||||
<button class="btn btn-primary ms-auto" id="onboarding-pair-connect" type="submit">Connect</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-scanner d-none" id="onboarding-scanner" aria-hidden="true">
|
||||
<div class="onboarding-scanner-panel" role="dialog" aria-modal="true" aria-labelledby="onboarding-scanner-title">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-3">
|
||||
<h2 class="h5 mb-0" id="onboarding-scanner-title">Scan player QR code</h2>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="onboarding-scanner-close" type="button">Close</button>
|
||||
</div>
|
||||
<video id="onboarding-scanner-video" class="onboarding-scanner-video" autoplay playsinline></video>
|
||||
<input id="onboarding-scanner-capture" type="file" accept="image/*" capture="environment" class="d-none">
|
||||
<p class="text-body-secondary small mb-0" id="onboarding-scanner-message">Point your camera at the player QR code.</p>
|
||||
<pre class="d-none small mt-2 mb-0" id="onboarding-scanner-debug" aria-live="polite"></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if (hasPermission currentUser 'clients.allow')}}
|
||||
{{#if (anyPermission currentUser 'clients.allow' 'pairing.allow')}}
|
||||
<div class="card card-outline card-secondary mb-4 screen-command-card">
|
||||
<div class="card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
@@ -13,9 +13,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if screens.length}}
|
||||
<div class="screen-command-panel">
|
||||
<div class="screen-command-panel-left">
|
||||
<div class="screen-command-panel{{#if (hasPermission currentUser 'clients.allow')}}{{else}} screen-command-panel-pairing-only{{/if}}">
|
||||
{{#if (hasPermission currentUser 'clients.allow')}}
|
||||
{{#if screens.length}}
|
||||
<div class="screen-command-panel-left">
|
||||
<div class="screen-command-selector-field">
|
||||
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
|
||||
<div class="input-group input-group-sm">
|
||||
@@ -55,18 +56,28 @@
|
||||
<button type="submit" class="btn btn-sm btn-secondary" disabled><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-command-panel-right">
|
||||
<div class="screen-command-summary">
|
||||
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
||||
<strong data-screen-command-name>Select a target screen group</strong>
|
||||
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
|
||||
</div>
|
||||
<div class="screen-command-panel-right">
|
||||
<div class="screen-command-summary">
|
||||
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
||||
<strong data-screen-command-name>Select a target screen group</strong>
|
||||
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">No screen groups are available to target yet.</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'pairing.allow')}}
|
||||
<div class="screen-command-pairing">
|
||||
<div>
|
||||
<strong>New player?</strong>
|
||||
<span class="screen-command-summary-meta">Open the pairing page to connect another display.</span>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="/pairing"><i class="bi bi-qr-code-scan me-1" aria-hidden="true"></i>Pair a player</a>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">No screen groups are available to target yet.</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -96,7 +107,7 @@
|
||||
<tbody id="dashboard-clients-table-body">
|
||||
{{#if clients.length}}
|
||||
{{#each clients}}
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
|
||||
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{id}}" data-client-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
|
||||
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
||||
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
||||
</td>
|
||||
@@ -186,7 +197,7 @@
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="command" value="moveclient" />
|
||||
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
|
||||
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
|
||||
<input type="hidden" name="clientId" value="" data-client-move-client-id />
|
||||
<input type="hidden" name="clientName" value="" data-client-move-client-name />
|
||||
<input type="hidden" name="playerBaseUrl" value="" data-client-move-player-base-url />
|
||||
<label class="form-label" for="client-move-screen-target">Target screen group</label>
|
||||
|
||||
@@ -11,13 +11,6 @@
|
||||
<span class="dashboard-hero-kicker">Live overview</span>
|
||||
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
|
||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen group snapshot when you want a quick read on playlist assignment and live connections.</p>
|
||||
{{#if primaryPlayerUrl}}
|
||||
<div class="alert alert-info mb-0 mt-3 py-2">
|
||||
<strong>Begin onboarding:</strong>
|
||||
open <a href="{{primaryPlayerUrl}}" target="_blank" rel="noreferrer">{{primaryPlayerUrl}}</a>
|
||||
on the public display to get started.
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="dashboard-hero-stats">
|
||||
{{#if (hasPermission currentUser "playlists.read")}}
|
||||
@@ -194,7 +187,9 @@
|
||||
<h3 class="card-title">Screen group snapshot</h3>
|
||||
<p class="dashboard-card-subtitle">Playlist assignment and live connection state without the spreadsheet feel.</p>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
|
||||
{{#if (hasPermission currentUser "pairing.allow")}}
|
||||
<a class="btn btn btn-primary" href="/pairing"><i class="bi bi-qr-code-scan me-1" aria-hidden="true"></i>Pair a player</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
|
||||
|
||||
@@ -180,13 +180,27 @@
|
||||
|
||||
var backgroundColor = String(payload.backgroundColor || '#111111').trim() || '#111111';
|
||||
var backgroundImagePath = String(payload.backgroundImagePath || '').trim();
|
||||
var backgroundGradient = '';
|
||||
try {
|
||||
var gradientData = typeof payload.backgroundGradient === 'string' ? JSON.parse(payload.backgroundGradient) : payload.backgroundGradient;
|
||||
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
|
||||
var gradientStops = 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 }; });
|
||||
gradientStops = gradientStops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
|
||||
if (gradientStops.length >= 2) {
|
||||
var gradientAngle = Number(gradientData.angle);
|
||||
backgroundGradient = 'linear-gradient(' + (Number.isFinite(gradientAngle) ? Math.max(0, Math.min(360, gradientAngle)) : 90) + 'deg,' + gradientStops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
backgroundGradient = '';
|
||||
}
|
||||
|
||||
var canvasWidth = Math.max(1, Number(payload.canvasWidth || 1920));
|
||||
var canvasHeight = Math.max(1, Number(payload.canvasHeight || 1080));
|
||||
canvas.style.backgroundColor = backgroundColor;
|
||||
canvas.style.backgroundImage = backgroundImagePath ? 'url("' + encodeURI(backgroundImagePath) + '")' : 'none';
|
||||
canvas.style.backgroundImage = [backgroundImagePath ? 'url("' + encodeURI(backgroundImagePath) + '")' : '', backgroundGradient].filter(Boolean).join(',') || 'none';
|
||||
canvas.style.backgroundPosition = 'center';
|
||||
canvas.style.backgroundSize = '100% 100%';
|
||||
canvas.style.backgroundSize = backgroundImagePath ? 'contain, cover' : 'cover';
|
||||
canvas.style.backgroundRepeat = 'no-repeat';
|
||||
canvas.style.transformOrigin = 'center center';
|
||||
canvas.style.width = canvasWidth + 'px';
|
||||
|
||||
@@ -110,6 +110,10 @@
|
||||
<label for="background-color" class="form-label">Background colour</label>
|
||||
<input type="color" name="background_color" id="background-color" class="form-control form-control-color w-100" value="{{#if template.background_color}}{{template.background_color}}{{else}}#111111{{/if}}" title="Choose a background colour" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#background-advanced-modal"><i class="bi bi-sliders me-1" aria-hidden="true"></i>Advanced background</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,6 +121,23 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{#> modal-shell modalId="background-advanced-modal" modalLabelId="background-advanced-modal-label" modalDialogClass="modal-dialog-centered"}}
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="background-advanced-modal-label">Advanced background</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="background-gradient-enabled" {{#if template.background_gradient}}checked{{/if}} />
|
||||
<label class="form-check-label" for="background-gradient-enabled">Use linear gradient</label>
|
||||
</div>
|
||||
<div class="row g-3" id="background-gradient-options" {{#if template.background_gradient}}{{else}}hidden{{/if}}>
|
||||
<div class="col-12"><label for="background-gradient-angle" class="form-label">Gradient angle <output id="background-gradient-angle-output">90</output>°</label><input type="range" id="background-gradient-angle" class="form-range" min="0" max="360" value="90" /></div>
|
||||
<div class="col-12"><div class="gradient-stop-bar mb-3" id="background-gradient-bar" role="slider" aria-label="Gradient colour stops" aria-valuemin="0" aria-valuemax="100"><div class="gradient-stop-bar-track"></div><div class="gradient-stop-bar-handles" id="background-gradient-bar-handles"></div></div><div class="d-flex justify-content-between align-items-center mb-2"><strong>Colour stops</strong><button type="button" class="btn btn-sm btn-outline-secondary" id="background-gradient-add-stop"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add stop</button></div><div class="d-grid gap-2" id="background-gradient-stops"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
|
||||
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user