Release v2.4.2

This commit is contained in:
2026-08-03 12:45:27 +01:00
parent 2b9cabdab2
commit e95928f31c
141 changed files with 7694 additions and 5078 deletions
+315 -115
View File
@@ -19,7 +19,123 @@
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
var actionPath = '';
try {
actionPath = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionPath = String(form.action || '');
}
if (!/\/delete(?:\/|$)/.test(actionPath)) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to delete item.');
error.status = response.status;
throw error;
});
}
if (isLoginRedirect(response)) {
window.location.replace(response.url || '/login');
return;
}
window.location.replace(response.url || window.location.href);
}).catch(function (error) {
if (typeof showToast === 'function') {
var status = Number(error && error.status);
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to delete item.', variant);
return;
}
window.alert(error.message || 'Unable to delete item.');
}).finally(function () {
delete form.dataset.busy;
});
});
}
function initDeleteButtons() {
document.addEventListener('click', function (event) {
var deleteButton = event.target.closest('[data-delete-action-url]');
if (!deleteButton) {
return;
}
if (deleteButton.disabled || (deleteButton.dataset && deleteButton.dataset.busy === 'true')) {
event.preventDefault();
return;
}
var actionUrl = deleteButton.getAttribute('data-delete-action-url');
if (!actionUrl) {
return;
}
var message = deleteButton.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
deleteButton.dataset.busy = 'true';
fetch(actionUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to delete item.');
error.status = response.status;
throw error;
});
}
if (isLoginRedirect(response)) {
window.location.replace(response.url || '/login');
return;
}
window.location.replace(response.url || window.location.href);
}).catch(function (error) {
if (typeof showToast === 'function') {
var status = Number(error && error.status);
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to delete item.', variant);
return;
}
window.alert(error.message || 'Unable to delete item.');
}).finally(function () {
delete deleteButton.dataset.busy;
});
});
}
@@ -79,6 +195,191 @@
}, true);
}
function parseAsyncSaveResponseMessage(responseText) {
var savedMessage = '';
try {
var responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
return savedMessage;
}
function getAsyncSaveActionField(form) {
if (!form || !form.querySelector) {
return null;
}
return form.querySelector('input[type="hidden"][name="save_action"]') || form.querySelector('input[type="hidden"][name="action"]');
}
function isLoginRedirect(response) {
if (!response || !response.redirected || !response.url) {
return false;
}
try {
return new URL(response.url, window.location.href).pathname === '/login';
} catch (_error) {
return /\/login(?:[?#]|$)/.test(String(response.url || ''));
}
}
async function submitAsyncSaveForm(form, event, options) {
var settings = options || {};
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return false;
}
if (form.dataset && form.dataset.busy === 'true') {
if (event) {
event.preventDefault();
}
return false;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
if (event) {
event.preventDefault();
}
return false;
}
if (event) {
event.preventDefault();
}
form.dataset.busy = 'true';
try {
if (typeof settings.beforeSubmit === 'function') {
var beforeResult = await settings.beforeSubmit(form, event);
if (beforeResult === false) {
return false;
}
}
var hiddenSaveAction = getAsyncSaveActionField(form);
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event && event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
var response = await fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(await response.text() || (settings.errorMessage || 'Unable to save changes.'));
}
if (isLoginRedirect(response)) {
window.location.replace(response.url || '/login');
return true;
}
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
window.location.replace(redirectUrl);
return true;
}
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
return true;
}
var responseText = await response.text();
var responseDocument = null;
try {
responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
} catch (_error) {
responseDocument = null;
}
if (typeof settings.afterSuccess === 'function') {
await settings.afterSuccess({
form: form,
response: response,
responseText: responseText,
responseDocument: responseDocument,
submitterValue: submitterValue
});
}
clearFormDirty(form);
var successMessage = typeof settings.getSuccessMessage === 'function'
? settings.getSuccessMessage({
form: form,
response: response,
responseText: responseText,
responseDocument: responseDocument,
submitterValue: submitterValue
})
: parseAsyncSaveResponseMessage(responseText);
showToast(successMessage || String(settings.fallbackSuccessMessage || 'Saved.'), 'success');
return true;
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'), variant);
} else {
window.alert(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'));
}
return false;
} finally {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (typeof settings.afterFinally === 'function') {
settings.afterFinally(form);
}
var hiddenInput = getAsyncSaveActionField(form);
if (hiddenInput) {
hiddenInput.value = '';
}
}
}
window.webAsyncSaveForm = window.webAsyncSaveForm || {};
window.webAsyncSaveForm.submit = submitAsyncSaveForm;
window.webAsyncSaveForm.parseResponseMessage = parseAsyncSaveResponseMessage;
function initAsyncCommandForms() {
function getAnnouncementActionState(actionPath) {
var isPlay = /\/play$/.test(actionPath);
@@ -174,7 +475,7 @@
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
var hiddenInput = getAsyncSaveActionField(form);
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
@@ -208,6 +509,9 @@
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') {
window.rebindPlaylistTableDrag(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -311,7 +615,7 @@
return;
}
var button = target.closest('button[name="save_action"]');
var button = target.closest('button[name="save_action"], button[name="action"]');
if (!button) {
return;
}
@@ -330,129 +634,24 @@
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
if (form.hasAttribute('data-async-save-reload-on-success')) {
clearFormDirty(form);
window.location.replace(response.url || window.location.href);
return;
}
clearFormDirty(form);
return response.text().then(function (text) {
var savedMessage = '';
var responseDocument = null;
try {
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
submitAsyncSaveForm(form, event, {
fallbackSuccessMessage: 'Saved.',
afterSuccess: function (context) {
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(context.response.url || '', 'refresh_task_id');
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
} else if (refreshTargetSelector && context.responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
var nextTarget = context.responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
@@ -651,6 +850,7 @@
}
initConfirmForms();
initDeleteButtons();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
+319 -6
View File
@@ -6,20 +6,97 @@
var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var latestDashboardState = null;
function getClientMoveModalElements() {
return {
modal: document.getElementById('client-move-screen-modal'),
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]'),
clientNameInput: document.querySelector('[data-client-move-client-name]')
};
}
function getClientMoveScreens() {
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
}
var select = document.getElementById('client-move-screen-target');
if (!select || !select.options) {
return [];
}
return Array.prototype.slice.call(select.options).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.textContent || option.value || '').trim()
};
}).filter(function (screen) {
return Boolean(screen && screen.slug);
});
}
function updateClientMoveModalFromRow(row) {
var elements = getClientMoveModalElements();
if (!elements.form || !elements.targetSelect || !row) {
return;
}
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
options.forEach(function (option) {
option.disabled = false;
if (String(option.value || '').trim() === currentScreenSlug) {
option.disabled = true;
}
});
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
}
elements.targetSelect.value = '';
if (elements.form.querySelector('button[type="submit"]')) {
elements.form.querySelector('button[type="submit"]').disabled = false;
}
}
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info';
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
var pauseButtonLabel = paused ? 'Resume client' : 'Pause client';
var pauseButtonText = paused ? 'Resume' : 'Pause';
var blackout = Boolean(client.blackout);
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? 'false' : 'true';
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
return [
'<div class="actions justify-content-end">',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
'</div>'
].join('');
}
function updateClientActionCell(cell, client) {
@@ -35,6 +112,8 @@
var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
pauseButton.setAttribute('aria-label', paused ? 'Resume client' : 'Pause client');
pauseButton.setAttribute('title', paused ? 'Resume client' : 'Pause client');
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
var pauseForm = pauseButton.form;
@@ -52,7 +131,9 @@
var reloadButton = cell.querySelector('button[data-action="reload"]');
if (reloadButton) {
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload';
reloadButton.setAttribute('aria-label', 'Reload client');
reloadButton.setAttribute('title', 'Reload client');
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat" aria-hidden="true"></i>';
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
var reloadForm = reloadButton.form;
if (reloadForm) {
@@ -74,6 +155,8 @@
var blackout = Boolean(client.blackout);
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
blackoutButton.setAttribute('aria-label', blackout ? 'Restore client' : 'Blackout client');
blackoutButton.setAttribute('title', blackout ? 'Restore client' : 'Blackout client');
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
var blackoutForm = blackoutButton.form;
@@ -100,6 +183,7 @@
}
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
previousButton.setAttribute('aria-label', 'Previous slide');
previousButton.setAttribute('title', 'Previous slide');
var previousForm = previousButton.form;
if (previousForm) {
var previousCommandInput = previousForm.querySelector('input[name="command"]');
@@ -120,6 +204,7 @@
}
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
nextButton.setAttribute('aria-label', 'Next slide');
nextButton.setAttribute('title', 'Next slide');
var nextForm = nextButton.form;
if (nextForm) {
var nextCommandInput = nextForm.querySelector('input[name="command"]');
@@ -168,7 +253,7 @@
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
'<td data-label="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>',
@@ -328,6 +413,141 @@
grid.innerHTML = screens.map(renderScreenTile).join('');
}
function updateScreenCommandControls(state) {
var select = document.getElementById('screen-command-select');
if (!select) {
return;
}
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
var pill = document.querySelector('[data-screen-command-pill]');
var nameNode = document.querySelector('[data-screen-command-name]');
var metaNode = document.querySelector('[data-screen-command-meta]');
var screens = Array.isArray(state && state.screens) ? state.screens : [];
var screenBySlug = {};
screens.forEach(function (screen) {
if (screen && screen.slug) {
screenBySlug[String(screen.slug)] = screen;
}
});
if (!screens.length) {
select.value = '';
select.disabled = true;
forms.forEach(function (form) {
form.querySelectorAll('button, input').forEach(function (control) {
control.disabled = true;
});
});
if (pill) {
pill.classList.remove('is-live');
pill.classList.add('is-idle');
pill.textContent = 'No screens';
}
if (nameNode) {
nameNode.textContent = 'No target available';
}
if (metaNode) {
metaNode.textContent = 'Create a screen before using screen-level commands.';
}
return;
}
select.disabled = false;
if (!select.value || !screenBySlug[select.value]) {
select.value = screens[0].slug || '';
}
var selectedScreen = screenBySlug[select.value] || screens[0];
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
return String(client && client.screen_slug || '').trim() === selectedSlug;
}) : [];
var connectionCount = selectedClients.length;
var hasClients = connectionCount > 0;
var allPaused = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && selectedClients.every(function (client) {
return Boolean(client && client.blackout);
});
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
if (pill) {
pill.classList.toggle('is-live', hasClients);
pill.classList.toggle('is-idle', !hasClients);
pill.textContent = connectionLabel;
}
if (nameNode) {
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
}
if (metaNode) {
metaNode.textContent = 'Commands sent here target every client currently using this screen.';
}
forms.forEach(function (form) {
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
var commandInput = form.querySelector('input[name="command"]');
var button = form.querySelector('button[type="submit"]');
if (commandInput) {
if (command === 'pause') {
commandInput.value = allPaused ? 'pause' : 'pause';
var pauseStateInput = form.querySelector('input[name="paused"]');
if (pauseStateInput) {
pauseStateInput.value = allPaused ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen');
}
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
} else if (command === 'blackout') {
commandInput.value = 'blackout';
var blackoutStateInput = form.querySelector('input[name="blackout"]');
if (blackoutStateInput) {
blackoutStateInput.value = allBlackout ? 'false' : 'true';
}
if (button) {
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen');
}
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
} else {
commandInput.value = command || commandInput.value || '';
}
}
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
if (command === 'reload') {
form.setAttribute('data-confirm-message', 'Reload selected screen?');
if (button) {
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen';
}
}
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
control.disabled = !selectedSlug;
});
});
}
function readScreenCommandStateFromDom() {
var select = document.getElementById('screen-command-select');
if (!select) {
return { screens: [] };
}
return {
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
return {
slug: String(option.value || '').trim(),
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
};
})
};
}
function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
@@ -378,8 +598,10 @@
if (!state) {
return;
}
latestDashboardState = state;
updateStats(state);
updateScreenGrid(state);
updateScreenCommandControls(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
@@ -468,7 +690,98 @@
});
}
function initClientMoveHandler() {
var elements = getClientMoveModalElements();
if (!elements.modal || !elements.form || !elements.targetSelect) {
return;
}
document.addEventListener('click', function (event) {
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
if (!moveButton) {
return;
}
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
if (!row) {
return;
}
updateClientMoveModalFromRow(row);
});
elements.form.addEventListener('submit', function (event) {
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
if (!targetScreenSlug) {
event.preventDefault();
window.alert('Choose a target screen.');
return;
}
event.preventDefault();
elements.form.dataset.busy = 'true';
var formData = new FormData(elements.form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(elements.form.action, {
method: (elements.form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to move client.');
try {
var payload = JSON.parse(text);
if (payload && (payload.error || payload.message)) {
error = new Error(String(payload.error || payload.message));
}
} catch (_error) {
// fall back to the raw text body
}
throw error;
});
}
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(elements.modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
}
return response.json().catch(function () {
return { ok: true };
});
}).catch(function (error) {
window.alert(error && error.message ? error.message : 'Unable to move client.');
}).finally(function () {
delete elements.form.dataset.busy;
});
});
}
window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler();
initClientMoveHandler();
var screenCommandSelect = document.getElementById('screen-command-select');
if (screenCommandSelect) {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
screenCommandSelect.addEventListener('change', function () {
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
});
}
}());
@@ -1,35 +0,0 @@
// Minimal row editor for schedule groups.
(function () {
var form = document.getElementById('schedule-group-form');
var body = document.querySelector('[data-schedule-entries-body]');
var addButton = document.querySelector('[data-add-schedule-entry]');
var template = document.getElementById('schedule-entry-row-template');
if (!form || !body || !addButton || !template) {
return;
}
function bindRemove(row) {
var button = row.querySelector('[data-remove-schedule-entry]');
if (!button) {
return;
}
button.addEventListener('click', function () {
row.remove();
});
}
function addRow() {
var fragment = template.content.cloneNode(true);
var row = fragment.querySelector('[data-schedule-entry-row]');
if (!row) {
return;
}
bindRemove(row);
body.appendChild(fragment);
}
body.querySelectorAll('[data-schedule-entry-row]').forEach(bindRemove);
addButton.addEventListener('click', addRow);
}());
@@ -0,0 +1,121 @@
// Minimal row editor for timetable groups.
(function () {
var form = document.getElementById('timetable-group-form');
var body = document.querySelector('[data-timetable-entries-body]');
var addButton = document.querySelector('[data-add-timetable-entry]');
var template = document.getElementById('timetable-entry-row-template');
if (!form || !body || !addButton || !template) {
return;
}
function pad(value) {
return String(value).padStart(2, '0');
}
function formatDateTimeLocalValue(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return '';
}
return [
String(date.getFullYear()).padStart(4, '0'),
'-',
pad(date.getMonth() + 1),
'-',
pad(date.getDate()),
'T',
pad(date.getHours()),
':',
pad(date.getMinutes())
].join('');
}
function parseUtcDateTimeLocalValue(value) {
var raw = String(value || '').trim();
if (!raw) {
return null;
}
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z';
var date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
function toUtcDateTimeLocalValue(value) {
var localDate = new Date(String(value || '').trim());
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
}
function syncRowValuesToLocal(row) {
if (!row) {
return;
}
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
var input = row.querySelector('[name="' + name + '"]');
if (!input || input.dataset.timetableTimezoneSynced === 'true') {
return;
}
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value));
if (localValue) {
input.value = localValue;
}
input.dataset.timetableTimezoneSynced = 'true';
});
}
function syncFormDataToUtc(formData) {
var rows = body.querySelectorAll('[data-timetable-entry-row]');
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
formData.delete(name);
});
rows.forEach(function (row) {
var idInput = row.querySelector('[name="entry_id[]"]');
var titleInput = row.querySelector('[name="entry_title[]"]');
var descriptionInput = row.querySelector('[name="entry_short_description[]"]');
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
formData.append('entry_id[]', idInput ? idInput.value : '');
formData.append('entry_title[]', titleInput ? titleInput.value : '');
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : '');
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : '');
});
}
function bindRemove(row) {
var button = row.querySelector('[data-remove-timetable-entry]');
if (!button) {
return;
}
button.addEventListener('click', function () {
row.remove();
});
}
function addRow() {
var fragment = template.content.cloneNode(true);
var row = fragment.querySelector('[data-timetable-entry-row]');
if (!row) {
return;
}
bindRemove(row);
body.appendChild(fragment);
}
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
bindRemove(row);
syncRowValuesToLocal(row);
});
form.addEventListener('formdata', function (event) {
syncFormDataToUtc(event.formData);
});
addButton.addEventListener('click', addRow);
}());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
(function () {
// Playlist table drag sorting is isolated here so the playlist editor script
// can stay focused on slide picking, schedule editing, and row updates.
function getDragEventPoint(event) {
var touch = event.touches && event.touches[0] ? event.touches[0] : event.changedTouches && event.changedTouches[0] ? event.changedTouches[0] : null;
return {
x: touch ? touch.clientX : event.clientX,
y: touch ? touch.clientY : event.clientY
};
}
function measureDraggedRowWidths(row) {
var metrics = {
rowRect: row.getBoundingClientRect(),
cellRects: []
};
Array.prototype.forEach.call(row.children, function (cell) {
metrics.cellRects.push(cell.getBoundingClientRect());
});
return metrics;
}
function lockDraggedRowWidths(row, metrics) {
if (!row) {
return;
}
var rowRect = metrics && metrics.rowRect ? metrics.rowRect : row.getBoundingClientRect();
row.style.width = rowRect.width + 'px';
row.style.height = rowRect.height + 'px';
row.style.boxSizing = 'border-box';
Array.prototype.forEach.call(row.children, function (cell, index) {
var cellRect = metrics && metrics.cellRects && metrics.cellRects[index] ? metrics.cellRects[index] : cell.getBoundingClientRect();
cell.style.width = cellRect.width + 'px';
cell.style.height = cellRect.height + 'px';
cell.style.boxSizing = 'border-box';
});
}
function syncDraggedRowCellPadding(row, sourceRow) {
if (!row || !sourceRow) {
return;
}
Array.prototype.forEach.call(row.children, function (cell, index) {
var sourceCell = sourceRow.children[index];
if (!sourceCell) {
return;
}
var sourceStyle = window.getComputedStyle(sourceCell);
cell.style.paddingLeft = sourceStyle.paddingLeft;
cell.style.paddingRight = sourceStyle.paddingRight;
cell.style.paddingTop = sourceStyle.paddingTop;
cell.style.paddingBottom = sourceStyle.paddingBottom;
});
}
function unlockDraggedRowWidths(row) {
if (!row) {
return;
}
row.style.width = '';
row.style.height = '';
row.style.boxSizing = '';
Array.prototype.forEach.call(row.children, function (cell) {
cell.style.width = '';
cell.style.height = '';
cell.style.boxSizing = '';
});
}
function animateRowReorder(previousRects, excludedRows) {
var excluded = Array.isArray(excludedRows) ? excludedRows : [];
if (!previousRects || typeof previousRects.forEach !== 'function') {
return;
}
window.requestAnimationFrame(function () {
previousRects.forEach(function (previousRect, row) {
var nextRect;
var deltaY;
var previousTransition;
if (!row || excluded.indexOf(row) !== -1 || !row.parentNode) {
return;
}
nextRect = row.getBoundingClientRect();
deltaY = previousRect.top - nextRect.top;
if (!deltaY) {
return;
}
previousTransition = row.style.transition;
row.style.transition = 'none';
row.style.transform = 'translate3d(0, ' + deltaY + 'px, 0)';
row.offsetHeight;
row.style.transition = 'transform 180ms ease';
window.requestAnimationFrame(function () {
if (!row.parentNode) {
return;
}
row.style.transform = '';
var cleanup = function () {
row.style.transition = previousTransition;
row.removeEventListener('transitionend', cleanup);
row.removeEventListener('transitioncancel', cleanup);
};
row.addEventListener('transitionend', cleanup, { once: true });
row.addEventListener('transitioncancel', cleanup, { once: true });
});
});
});
}
function initPlaylistTableDrag(options) {
var tbody = options && options.tbody ? options.tbody : null;
var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
var rowDragState = null;
if (!tbody) {
return;
}
if (tbody.dataset && tbody.dataset.playlistTableDragBound === 'true') {
return;
}
if (tbody.dataset) {
tbody.dataset.playlistTableDragBound = 'true';
}
function getRows() {
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
}
function getDragHandle(target) {
return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
}
function prepareGhostRow(row) {
if (!row) {
return;
}
row.setAttribute('data-playlist-ghost-row', 'true');
row.setAttribute('aria-hidden', 'true');
row.classList.add('playlist-row-ghost');
row.style.pointerEvents = 'none';
Array.prototype.forEach.call(row.querySelectorAll('[id]'), function (node) {
node.removeAttribute('id');
});
Array.prototype.forEach.call(row.querySelectorAll('input, select, textarea, button'), function (control) {
control.disabled = true;
control.setAttribute('tabindex', '-1');
if (control.tagName === 'BUTTON') {
control.setAttribute('type', 'button');
}
});
}
function createDragLayer(row, width) {
var table = row.ownerDocument.createElement('table');
var tbodyElement = row.ownerDocument.createElement('tbody');
table.className = 'playlist-row-drag-layer';
table.style.position = 'fixed';
table.style.top = '0';
table.style.left = '0';
table.style.margin = '0';
table.style.borderCollapse = 'collapse';
table.style.borderSpacing = '0';
table.style.tableLayout = 'fixed';
table.style.pointerEvents = 'none';
table.style.zIndex = '1080';
table.style.width = width + 'px';
tbodyElement.appendChild(row);
table.appendChild(tbodyElement);
row.ownerDocument.body.appendChild(table);
return table;
}
function positionDragLayer(layer, metrics, point) {
if (!layer || !metrics || !point) {
return;
}
layer.style.transform = 'translate3d(' + (point.x - metrics.offsetX) + 'px, ' + (point.y - metrics.offsetY) + 'px, 0)';
}
function beginRowDrag() {
if (!rowDragState || rowDragState.active) {
return;
}
var row = rowDragState.row;
var metrics = rowDragState.metrics;
var placeholder = row.cloneNode(true);
var rowRect = metrics.rowRect;
prepareGhostRow(placeholder);
lockDraggedRowWidths(placeholder, metrics);
syncDraggedRowCellPadding(placeholder, row);
placeholder.classList.add('playlist-row-ghost');
tbody.insertBefore(placeholder, row);
rowDragState.placeholder = placeholder;
rowDragState.dragLayer = createDragLayer(row, metrics.rowRect.width);
rowDragState.active = true;
rowDragState.metrics.offsetX = rowDragState.startX - rowRect.left;
rowDragState.metrics.offsetY = rowDragState.startY - rowRect.top;
lockDraggedRowWidths(row, metrics);
syncDraggedRowCellPadding(row, placeholder);
row.classList.add('playlist-row-dragging');
row.style.pointerEvents = 'none';
row.setAttribute('aria-grabbed', 'true');
positionDragLayer(rowDragState.dragLayer, metrics, rowDragState.currentPoint || {
x: rowDragState.startX,
y: rowDragState.startY
});
}
function repositionGhostRow(clientY) {
var rows = getRows();
var insertBeforeRow = null;
var previousRects = new Map();
var moved = false;
rows.forEach(function (row) {
if (row !== rowDragState.placeholder) {
previousRects.set(row, row.getBoundingClientRect());
}
});
for (var index = 0; index < rows.length; index += 1) {
var candidateRow = rows[index];
var rect = candidateRow.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
insertBeforeRow = candidateRow;
break;
}
}
if (insertBeforeRow) {
if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
moved = true;
}
} else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
tbody.appendChild(rowDragState.placeholder);
moved = true;
}
if (moved) {
animateRowReorder(previousRects, [rowDragState.row, rowDragState.placeholder]);
}
}
function teardownRowDrag() {
if (!rowDragState) {
return;
}
if (rowDragState.supportsPointerEvents) {
document.removeEventListener('pointermove', handleRowDragMove);
document.removeEventListener('pointerup', handleRowDragEnd);
document.removeEventListener('pointercancel', handleRowDragEnd);
} else {
document.removeEventListener('mousemove', handleRowDragMove);
document.removeEventListener('mouseup', handleRowDragEnd);
document.removeEventListener('touchmove', handleRowDragMove);
document.removeEventListener('touchend', handleRowDragEnd);
document.removeEventListener('touchcancel', handleRowDragEnd);
}
if (rowDragState.active) {
if (rowDragState.placeholder && rowDragState.placeholder.parentNode) {
rowDragState.placeholder.parentNode.insertBefore(rowDragState.row, rowDragState.placeholder);
rowDragState.placeholder.parentNode.removeChild(rowDragState.placeholder);
} else if (rowDragState.row.parentNode !== tbody) {
tbody.appendChild(rowDragState.row);
}
if (rowDragState.dragLayer && rowDragState.dragLayer.parentNode) {
rowDragState.dragLayer.parentNode.removeChild(rowDragState.dragLayer);
}
rowDragState.row.classList.remove('playlist-row-dragging');
rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
rowDragState.row.removeAttribute('aria-grabbed');
unlockDraggedRowWidths(rowDragState.row);
onOrderChanged();
}
rowDragState = null;
}
function handleRowDragMove(event) {
if (!rowDragState) {
return;
}
var point = getDragEventPoint(event);
if (!point) {
return;
}
rowDragState.currentPoint = point;
if (!rowDragState.active) {
var threshold = typeof rowDragState.threshold === 'number' ? rowDragState.threshold : 3;
if (Math.abs(point.x - rowDragState.startX) < threshold && Math.abs(point.y - rowDragState.startY) < threshold) {
return;
}
beginRowDrag();
}
event.preventDefault();
positionDragLayer(rowDragState.dragLayer, rowDragState.metrics, point);
repositionGhostRow(point.y);
}
function handleRowDragEnd() {
teardownRowDrag();
}
function handleRowDragStart(event) {
var handle = getDragHandle(event.target);
var row = handle ? handle.closest('tr[data-playlist-slide-row]') : null;
var point = getDragEventPoint(event);
if (!row || rowDragState) {
return;
}
if (event.type === 'mousedown' && event.button !== 0) {
return;
}
if (!point) {
return;
}
event.preventDefault();
event.stopPropagation();
rowDragState = {
row: row,
metrics: measureDraggedRowWidths(row),
startX: point.x,
startY: point.y,
currentPoint: point,
threshold: 3,
active: false,
previousPointerEvents: row.style.pointerEvents || '',
placeholder: null,
dragLayer: null,
supportsPointerEvents: typeof window !== 'undefined' && typeof window.PointerEvent === 'function'
};
if (rowDragState.supportsPointerEvents) {
document.addEventListener('pointermove', handleRowDragMove, { passive: false });
document.addEventListener('pointerup', handleRowDragEnd);
document.addEventListener('pointercancel', handleRowDragEnd);
} else {
document.addEventListener('mousemove', handleRowDragMove);
document.addEventListener('mouseup', handleRowDragEnd);
document.addEventListener('touchmove', handleRowDragMove, { passive: false });
document.addEventListener('touchend', handleRowDragEnd);
document.addEventListener('touchcancel', handleRowDragEnd);
}
}
// The drag handle is intentionally isolated from the rest of the playlist
// editor so it can be swapped or removed without touching schedule logic.
if (typeof window !== 'undefined' && window.PointerEvent) {
tbody.addEventListener('pointerdown', handleRowDragStart);
} else {
tbody.addEventListener('mousedown', handleRowDragStart);
tbody.addEventListener('touchstart', handleRowDragStart, { passive: false });
}
}
window.initPlaylistTableDrag = initPlaylistTableDrag;
}());
+54 -52
View File
@@ -1,4 +1,4 @@
// Schedule region helpers for editor previews and defaults.
// Timetable region helpers for editor previews and defaults.
(function () {
var registry = window.pulseRegionTypes;
@@ -27,13 +27,13 @@
};
}
function getScheduleGroups() {
return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : [];
function getTimetableGroups() {
return Array.isArray(window.initialData && window.initialData.timetableGroups) ? window.initialData.timetableGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
@@ -81,6 +81,12 @@
return isUpcoming(entry, now);
});
if (!entries.length && mode === 'upcoming') {
entries = getEntries(groupId, groups).filter(function (entry) {
return isLive(entry, now);
});
}
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
@@ -112,7 +118,7 @@
function getCurrentConfig(region, existingContent) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
max_items: Math.max(1, Number(current.max_items || 5)),
@@ -137,18 +143,18 @@
}).join('');
}
function getCurrentSelection(regionId, card, existingContent, scheduleGroups) {
function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
var current = existingContent[regionId] || {};
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + regionId + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + regionId + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + regionId + '"]') : null;
var groupId = scheduleGroupInput ? scheduleGroupInput.value : (current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id));
var displayMode = displayModeInput ? displayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(maxItemsInput ? maxItemsInput.value : current.max_items || 5));
var group = getGroupById(groupId, scheduleGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, scheduleGroups);
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5));
var group = getGroupById(groupId, timetableGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
if (!entries.length) {
entries = getEntries(groupId, scheduleGroups).slice(0, maxItems);
entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
}
return {
@@ -162,17 +168,17 @@
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [];
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
var currentGroupId = current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id);
var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
var currentGroup = getGroupById(currentGroupId, scheduleGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, scheduleGroups);
var currentGroup = getGroupById(currentGroupId, timetableGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
if (!currentEntries.length) {
currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems);
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
}
var groupOptions = scheduleGroups.map(function (group) {
var groupOptions = timetableGroups.map(function (group) {
var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : '';
return '<option value="' + escapeHtml(group.id) + '"' + selected + '>' + escapeHtml(group.name || ('Group ' + group.id)) + '</option>';
}).join('');
@@ -181,7 +187,7 @@
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<div class="template-field-actions"><span class="chip">Schedule</span></div>' +
'<div class="template-field-actions"><span class="chip">Timetable</span></div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="editor-holder" data-region-id="' + region.id + '">' +
@@ -190,33 +196,32 @@
'</div>' +
'<div class="row g-3">' +
'<div class="col-12 col-lg-6">' +
'<label class="form-label" for="region_schedule_group_id_' + region.id + '">Schedule group</label>' +
'<select id="region_schedule_group_id_' + region.id + '" name="region_schedule_group_id_' + region.id + '" class="form-select">' +
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_group_id_' + region.id + '" class="form-select">' +
'<option value="">Select a group</option>' +
groupOptions +
'</select>' +
'</div>' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_display_mode_' + region.id + '">Display mode</label>' +
'<select id="region_schedule_display_mode_' + region.id + '" name="region_schedule_display_mode_' + region.id + '" class="form-select">' +
'<label class="form-label" for="region_timetable_display_mode_' + region.id + '">Display mode</label>' +
'<select id="region_timetable_display_mode_' + region.id + '" name="region_timetable_display_mode_' + region.id + '" class="form-select">' +
'<option value="upcoming"' + (currentDisplayMode === 'upcoming' ? ' selected' : '') + '>Upcoming</option>' +
'<option value="current"' + (currentDisplayMode === 'current' ? ' selected' : '') + '>Current</option>' +
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="row g-3 align-items-end">' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_max_items_' + region.id + '">Max items</label>' +
'<input id="region_schedule_max_items_' + region.id + '" type="number" min="1" step="1" name="region_schedule_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'</div>' +
'<div class="col-12 col-lg-9 text-body-secondary small">Use the selected group and display mode to choose which schedule entry fields are available.</div>' +
'</div>' +
'<div class="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-2">Format start and end values with expressions like <code>{{start.format("MMM D, YYYY h:mm A")}}</code> and <code>{{end.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
}
@@ -241,7 +246,7 @@
return;
}
var groups = getScheduleGroups();
var groups = getTimetableGroups();
var current = getCurrentSelection(regionId, card, {}, groups);
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
}
@@ -255,7 +260,7 @@
return;
}
if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) {
if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
return;
}
@@ -270,15 +275,15 @@
return {
region: context.region,
current: context.current || {},
scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []
timetableGroups: Array.isArray(context.timetableGroups) ? context.timetableGroups : []
};
}
function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) {
function buildPreviewRenderContext(region, card, existingContent, timetableGroups) {
var current = getCurrentConfig(region, existingContent || {});
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]') : null;
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]') : null;
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]') : null;
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]') : null;
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
@@ -287,33 +292,30 @@
return {
value: value,
style: getTextStyle(region, current),
schedule_group_id: scheduleGroupInput ? scheduleGroupInput.value || current.schedule_group_id : current.schedule_group_id,
display_mode: displayModeInput ? displayModeInput.value || current.display_mode : current.display_mode,
max_items: maxItemsInput ? maxItemsInput.value || current.max_items : current.max_items,
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {},
scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : []
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
};
}
function renderPreview(region, regionContent, context) {
var groups = context && context.scheduleGroups ? context.scheduleGroups : [];
var groups = context && context.timetableGroups ? context.timetableGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
if (!entries.length) {
entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
}
if (!value) {
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
}
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="schedule-region-entry" data-schedule-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
return '<div class="slide-preview-region timetable" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
@@ -323,8 +325,8 @@
}).join('') + '</div>';
}
registry.register('schedule', {
label: 'Schedule',
registry.register('timetable', {
label: 'Timetable',
getDefaultStyle: getDefaultStyle,
getDefaultRegionSize: function () {
return { width: 520, height: 280 };
+99 -22
View File
@@ -8,7 +8,7 @@ export function createSlideFormRegionHelpers(options) {
var existingContent = settings.existingContent || {};
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
var scheduleGroups = Array.isArray(settings.scheduleGroups) ? settings.scheduleGroups : [];
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var placeholderUtils = window.placeholderUtils || {};
var placeholderChips = window.placeholderChips || {};
@@ -460,10 +460,10 @@ export function createSlideFormRegionHelpers(options) {
};
}
function getCurrentScheduleConfig(region) {
function getCurrentTimetableConfig(region) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
max_items: Math.max(1, Number(current.max_items || 5))
};
@@ -480,13 +480,13 @@ export function createSlideFormRegionHelpers(options) {
if (module && typeof module.buildEditorCardContext === 'function') {
return module.buildEditorCardContext({
region: region,
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'schedule' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : getCurrentRssConfig(region),
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
style: getCurrentTextStyle(region),
fontSize: getCurrentTextStyle(region).font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -503,8 +503,8 @@ export function createSlideFormRegionHelpers(options) {
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
scheduleGroups: scheduleGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
timetableGroups: timetableGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -521,7 +521,7 @@ export function createSlideFormRegionHelpers(options) {
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: currentContent.disable_audio,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : rssConfig,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
style: textStyle,
fontSize: textStyle.font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -538,7 +538,7 @@ export function createSlideFormRegionHelpers(options) {
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
scheduleGroups: scheduleGroups
timetableGroups: timetableGroups
};
}
@@ -548,13 +548,89 @@ export function createSlideFormRegionHelpers(options) {
return '';
}
return module.renderEditorCard(buildRegionEditorCardContext(region));
return makeRegionCardCollapsible(module.renderEditorCard(buildRegionEditorCardContext(region)), region);
}
function makeRegionCardCollapsible(html, region) {
if (typeof html !== 'string' || !html) {
return html;
}
if (html.indexOf('data-lte-toggle="card-collapse"') !== -1) {
return html;
}
var cardHtml = html.replace(
'class="card card-outline card-secondary admin-form-card template-field-card mb-3"',
'class="card card-widget card-outline card-secondary admin-form-card template-field-card mb-3"'
);
if (cardHtml === html) {
return html;
}
var bodyOpenMatch = cardHtml.match(/<div class="card-body([^"]*)">/);
if (bodyOpenMatch) {
var bodyClassTokens = String(bodyOpenMatch[1] || '')
.trim()
.split(/\s+/)
.filter(Boolean);
var wrapperGapTokens = bodyClassTokens.filter(function (token) {
return /^gap-\d+$/i.test(token);
});
var wrapperPaddingTokens = bodyClassTokens.filter(function (token) {
return /^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
});
var bodyClassTokensWithoutGrid = bodyClassTokens.filter(function (token) {
return token !== 'd-grid' && !/^gap-\d+$/i.test(token) && !/^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
});
var bodyClassAttribute = ' class="card-body p-0' + (bodyClassTokensWithoutGrid.length ? ' ' + bodyClassTokensWithoutGrid.join(' ') : '') + '"';
var wrapperClassTokens = ['template-field-body', 'd-grid']
.concat(wrapperPaddingTokens)
.concat(wrapperGapTokens);
var wrapperClassAttribute = ' class="' + wrapperClassTokens.filter(Boolean).join(' ') + '"';
cardHtml = cardHtml.replace(bodyOpenMatch[0], '<div' + bodyClassAttribute + '><div' + wrapperClassAttribute + '>');
var lastCloseIndex = cardHtml.lastIndexOf('</div>');
if (lastCloseIndex !== -1) {
var bodyCloseIndex = cardHtml.lastIndexOf('</div>', lastCloseIndex - 1);
if (bodyCloseIndex !== -1) {
cardHtml = cardHtml.slice(0, bodyCloseIndex) + '</div>' + cardHtml.slice(bodyCloseIndex);
}
}
}
var collapseLabel = region && region.label ? String(region.label) : 'region';
var collapseButton = '' +
'<button type="button" class="btn btn-tool p-0 ms-auto text-body-secondary template-field-collapse" data-lte-toggle="card-collapse" aria-label="Collapse ' + escapeHtml(collapseLabel) + '" title="Collapse ' + escapeHtml(collapseLabel) + '">' +
'<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>' +
'<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>' +
'<span class="visually-hidden">Collapse ' + escapeHtml(collapseLabel) + '</span>' +
'</button>';
var bodyMarkerIndex = cardHtml.indexOf('<div class="card-body');
if (bodyMarkerIndex === -1) {
return cardHtml;
}
var actionsMarker = '<div class="template-field-actions">';
if (cardHtml.indexOf(actionsMarker) !== -1) {
return cardHtml.replace(/(<div class="template-field-actions">[\s\S]*?)<\/div>/, '$1' + collapseButton + '</div>');
}
var headerCloseIndex = cardHtml.lastIndexOf('</div>', bodyMarkerIndex);
if (headerCloseIndex === -1) {
return cardHtml;
}
return cardHtml.slice(0, headerCloseIndex) + collapseButton + cardHtml.slice(headerCloseIndex);
}
function getPreviewRegionContent(card, region) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
}
var current = existingContent[region.region_key];
@@ -604,14 +680,15 @@ export function createSlideFormRegionHelpers(options) {
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path;
} else if (region.region_type === 'schedule') {
var scheduleGroupInput = card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]');
var scheduleDisplayModeInput = card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]');
var scheduleMaxItemsInput = card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]');
var currentSchedule = getCurrentScheduleConfig(region);
content.schedule_group_id = scheduleGroupInput ? scheduleGroupInput.value : currentSchedule.schedule_group_id;
content.display_mode = scheduleDisplayModeInput ? scheduleDisplayModeInput.value : currentSchedule.display_mode;
content.max_items = scheduleMaxItemsInput ? scheduleMaxItemsInput.value : currentSchedule.max_items;
} else if (region.region_type === 'timetable') {
var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]');
var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]');
var timetableMaxItemsInput = card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]');
var currentSchedule = getCurrentTimetableConfig(region);
content.value = hiddenInput ? hiddenInput.value : (content.value || currentSchedule.value || '');
content.timetable_group_id = timetableGroupInput ? timetableGroupInput.value : currentSchedule.timetable_group_id;
content.display_mode = timetableDisplayModeInput ? timetableDisplayModeInput.value : currentSchedule.display_mode;
content.max_items = timetableMaxItemsInput ? timetableMaxItemsInput.value : currentSchedule.max_items;
} else {
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
}
@@ -622,7 +699,7 @@ export function createSlideFormRegionHelpers(options) {
function buildPreviewRenderContext(region, card) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -639,10 +716,10 @@ export function createSlideFormRegionHelpers(options) {
item_number: previewContent.item_number,
source_id: previewContent.source_id,
items_path: previewContent.items_path,
schedule_group_id: previewContent.schedule_group_id,
timetable_group_id: previewContent.timetable_group_id,
display_mode: previewContent.display_mode,
max_items: previewContent.max_items,
scheduleGroups: scheduleGroups,
timetableGroups: timetableGroups,
font_size: textStyle.font_size,
font_color: textStyle.font_color,
font_family: textStyle.font_family
+21 -83
View File
@@ -21,7 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var scheduleGroups = Array.isArray(slideEditorData.scheduleGroups) ? slideEditorData.scheduleGroups : [];
var timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {};
@@ -37,7 +37,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
var openPreviewPopupButton = document.getElementById('open-preview-popup');
var submitting = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
@@ -120,7 +119,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
existingContent: existingContent,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
timetableGroups: timetableGroups,
defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
@@ -643,6 +642,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
}).join('') : '<div class="muted">This template has no regions.</div>';
if (window.adminlte && typeof window.adminlte.CardWidget === 'function') {
templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) {
window.adminlte.CardWidget.getOrCreateInstance(card);
});
}
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
@@ -744,92 +749,25 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
requestPreviewRender();
}
slideForm.addEventListener('submit', async function (event) {
if (submitting) {
slideForm.addEventListener('submit', function (event) {
if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
return;
}
event.preventDefault();
var slideFormUrl = new URL(slideForm.action, window.location.href);
var isCreateForm = slideFormUrl.pathname === '/slides';
var submitterValue = event.submitter && event.submitter.name === 'save_action'
? String(event.submitter.value || '').trim().toLowerCase()
: '';
submitting = true;
try {
if (hasPendingRegionImageUpload()) {
throw new Error('Wait for the image upload to finish before saving.');
}
await slideFormEditorController.syncEditors();
var formData = new FormData(slideForm);
if (event.submitter && event.submitter.name) {
formData.set(event.submitter.name, event.submitter.value || '');
}
var response = await fetch(slideForm.action, {
method: (slideForm.method || 'POST').toUpperCase(),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
},
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(await response.text() || 'Unable to save slide.');
}
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
: String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
window.location.replace(redirectUrl);
return;
}
if (isCreateForm) {
try {
if (response.url) {
if (regionMediaController) {
regionMediaController.clearPendingUploadCleanupPaths();
}
window.location.replace(response.url);
return;
}
} catch (_error) {
// Fall through and show a toast if the redirect URL cannot be resolved.
window.webAsyncSaveForm.submit(slideForm, event, {
beforeSubmit: async function () {
if (hasPendingRegionImageUpload()) {
throw new Error('Wait for the image upload to finish before saving.');
}
slideForm.dataset.dirty = 'false';
showToast('Saved slide.', 'success');
return;
}
var responseText = await response.text();
var savedMessage = '';
try {
var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
var toastBody = doc.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
await slideFormEditorController.syncEditors();
},
fallbackSuccessMessage: 'Saved slide.',
afterSuccess: function () {
if (regionMediaController) {
regionMediaController.clearPendingUploadCleanupPaths();
}
} catch (_error) {
savedMessage = '';
}
showToast(savedMessage || 'Saved slide.', 'success');
slideForm.dataset.dirty = 'false';
if (regionMediaController) {
regionMediaController.clearPendingUploadCleanupPaths();
}
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
} else {
window.alert(error && error.message ? error.message : 'Unable to save slide.');
}
} finally {
submitting = false;
}
});
});
slideForm.addEventListener('input', function () {
+14 -3
View File
@@ -1,5 +1,10 @@
(function (root) {
function getToastTitle(variant) {
function getToastTitle(variant, message) {
var text = String(message || '').trim();
if (/\b(?:deleted|removed)\.?$/i.test(text)) {
return 'Deleted';
}
var textVariant = String(variant || '').trim().toLowerCase();
if (textVariant === 'danger') {
return 'Error';
@@ -142,6 +147,7 @@
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
var rules = [
{ variant: 'danger', patterns: [/\b(?:deleted|removed)\.?\s*$/i, /\b(?:deleted|removed)\b/i] },
{ variant: 'danger', patterns: [/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i, /\bdelete\b.*\b(?:before|first)\b/i, /\bstill (?:in use|linked|assigned|used)\b/i] },
{ variant: 'warning', patterns: [/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i] }
];
@@ -195,7 +201,7 @@
toast.setAttribute('data-toast-created-at', String(Date.now()));
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant, text), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
toast.querySelector('.toast-body').textContent = text;
setToastVariant(toast, toastVariant);
@@ -226,8 +232,13 @@
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
var bodyText = (toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '';
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant(bodyText, 'info');
setToastVariant(toast, existingVariant);
var title = toast.querySelector('.toast-header .me-auto');
if (title) {
title.textContent = getToastTitle(existingVariant, bodyText);
}
var instance = getBootstrapToast(toast);
if (instance) {