This commit is contained in:
@@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- No unreleased changes recorded yet.
|
||||
|
||||
## 1.5.12 - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
|
||||
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Template editing now blocks removal of regions that are still referenced by slides.
|
||||
|
||||
## 1.5.11 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.11",
|
||||
"version": "1.5.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.11",
|
||||
"version": "1.5.12",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.11",
|
||||
"version": "1.5.12",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
+2
-1
@@ -68,7 +68,8 @@ async function fetchTemplatesPage(pool, page, pageSize) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count
|
||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC`,
|
||||
|
||||
@@ -1050,7 +1050,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 18rem;
|
||||
overflow: hidden;
|
||||
/* overflow: hidden; */
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0;
|
||||
background: var(--bs-body-bg);
|
||||
@@ -1079,7 +1079,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
|
||||
.designer-overlay {
|
||||
z-index: 2;
|
||||
cursor: crosshair;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.designer-rect {
|
||||
@@ -1087,9 +1087,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
border: 2px solid rgba(13, 110, 253, 0.95);
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.35rem;
|
||||
border-radius: 0;
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.designer-rect.selected {
|
||||
@@ -1099,12 +1100,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
|
||||
.designer-rect-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1.55rem;
|
||||
left: 0.4rem;
|
||||
top: 0.35rem;
|
||||
max-width: 100%;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 37, 41, 0.88);
|
||||
background: rgba(33, 37, 41, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
@@ -1128,26 +1129,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
}
|
||||
|
||||
.resize-handle.nw {
|
||||
left: -0.4rem;
|
||||
top: -0.4rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.ne {
|
||||
right: -0.4rem;
|
||||
top: -0.4rem;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.sw {
|
||||
left: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translate(-50%, 50%);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.se {
|
||||
right: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transform: translate(50%, 50%);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
@@ -1376,8 +1381,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.region-item .remove-region {
|
||||
justify-self: end;
|
||||
.region-item [data-region-remove-button] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.region-item [data-region-remove-button]:disabled {
|
||||
opacity: 0.65;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.region-item .form-control:focus,
|
||||
@@ -1585,10 +1596,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-check {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
@@ -1856,16 +1863,6 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-hero-body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
(function () {
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.getAttribute) {
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function markFormDirty(form) {
|
||||
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
|
||||
return;
|
||||
}
|
||||
form.dataset.dirty = 'true';
|
||||
}
|
||||
|
||||
function clearFormDirty(form) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
form.dataset.dirty = 'false';
|
||||
}
|
||||
|
||||
function isFormDirty(form) {
|
||||
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
||||
}
|
||||
|
||||
function initDirtyTracking() {
|
||||
document.addEventListener('input', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initCancelConfirm() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
||||
if (!cancelTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
|
||||
if (!isFormDirty(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
||||
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 formData = new FormData(form);
|
||||
var body = new URLSearchParams();
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
|
||||
fetch(form.action, {
|
||||
method: (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'
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncSaveForms() {
|
||||
var refreshSequence = 0;
|
||||
|
||||
function setSaveActionValue(form, value) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'save_action';
|
||||
form.appendChild(hiddenInput);
|
||||
}
|
||||
|
||||
hiddenInput.value = String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getResponseQueryValue(responseUrl, key) {
|
||||
try {
|
||||
var url = new URL(responseUrl, window.location.href);
|
||||
return String(url.searchParams.get(key) || '').trim();
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function rebindRefreshTarget(targetElement) {
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initJsonTogglePanels === 'function') {
|
||||
window.initJsonTogglePanels(targetElement);
|
||||
}
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/xhtml+xml'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to refresh saved data.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (!currentTarget || !nextTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}).catch(function (_error) {
|
||||
// Ignore refresh replacement failures and leave the existing content in place.
|
||||
});
|
||||
}
|
||||
|
||||
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
|
||||
var pollDelayMs = 1000;
|
||||
var maxAttempts = 60;
|
||||
|
||||
function poll(attempt) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var stateUrl;
|
||||
try {
|
||||
stateUrl = new URL(refreshStateUrl, window.location.href);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
|
||||
|
||||
fetch(stateUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to check refresh status.');
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var status = String(payload && payload.status || '').trim().toLowerCase();
|
||||
if (status === 'queued' || status === 'running') {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
|
||||
}).catch(function () {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
poll(0);
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) {
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button[name="save_action"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = button.form || button.closest('form');
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveActionValue(form, button.value || '');
|
||||
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
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 = '';
|
||||
}
|
||||
|
||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||
refreshSequence += 1;
|
||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||
} else if (refreshTargetSelector && responseDocument) {
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = 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);
|
||||
}
|
||||
|
||||
function initSubmitOnChange() {
|
||||
var fields = document.querySelectorAll('[data-submit-on-change]');
|
||||
Array.prototype.forEach.call(fields, function (field) {
|
||||
field.addEventListener('change', function () {
|
||||
var form = field.form || field.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initJsonTogglePanels(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
||||
Array.prototype.forEach.call(panels, function (panel) {
|
||||
var output = panel.querySelector('[data-json-toggle-output]');
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
var card = panel.closest ? panel.closest('.card') : null;
|
||||
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
||||
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
||||
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
||||
var rawJson = '';
|
||||
try {
|
||||
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
|
||||
} catch (_error) {
|
||||
rawJson = '';
|
||||
}
|
||||
|
||||
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var parsedJson;
|
||||
try {
|
||||
parsedJson = JSON.parse(rawJson);
|
||||
} catch (_error) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var compactJson = JSON.stringify(parsedJson);
|
||||
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||
var isFormatted = true;
|
||||
|
||||
function syncButtonLabel() {
|
||||
if (!button || !label) {
|
||||
return;
|
||||
}
|
||||
label.textContent = isFormatted
|
||||
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
||||
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
||||
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function syncOutput() {
|
||||
output.textContent = isFormatted ? formattedJson : compactJson;
|
||||
syncButtonLabel();
|
||||
}
|
||||
|
||||
output.textContent = formattedJson;
|
||||
syncButtonLabel();
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
isFormatted = !isFormatted;
|
||||
syncOutput();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initLocalDateTimes(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var elements = scope.querySelectorAll('[data-local-datetime]');
|
||||
Array.prototype.forEach.call(elements, function (element) {
|
||||
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
var date = new Date(rawValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = formatDashboardDate(date);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
}());
|
||||
@@ -1,84 +1,11 @@
|
||||
(function () {
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
var webUiHelpers = window.webUiHelpers || {};
|
||||
var escapeHtml = webUiHelpers.escapeHtml;
|
||||
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
|
||||
@@ -1,83 +1,11 @@
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
var webUiHelpers = window.webUiHelpers || {};
|
||||
var escapeHtml = webUiHelpers.escapeHtml;
|
||||
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
(function () {
|
||||
// Shared constants used across the playlist schedule editor.
|
||||
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
var VIDEO_DURATION_LABEL = 'Use video duration';
|
||||
var VIDEO_DURATION_ACTIVE_LABEL = 'Use Video Duration';
|
||||
var VIDEO_DURATION_LOADING_LABEL = 'Loading...';
|
||||
var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail';
|
||||
var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist';
|
||||
var PICKER_CARD_CLASS = 'playlist-slide-picker-card';
|
||||
var PICKER_MEDIA_CLASS = 'playlist-slide-picker-media';
|
||||
var PICKER_TITLE_CLASS = 'playlist-slide-picker-title';
|
||||
var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||
var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||
var SCHEDULE_ROW_FIELD_NAMES = {
|
||||
mode: '[name="schedule_mode[]"]',
|
||||
startDatetime: '[name="schedule_start_datetime[]"]',
|
||||
endDatetime: '[name="schedule_end_datetime[]"]',
|
||||
startTime: '[name="schedule_start_time[]"]',
|
||||
endTime: '[name="schedule_end_time[]"]',
|
||||
daysJson: '[name="schedule_days_json[]"]'
|
||||
};
|
||||
|
||||
// Schedule helpers.
|
||||
function formatDays(daysValue) {
|
||||
var days = [];
|
||||
if (Array.isArray(daysValue)) {
|
||||
@@ -21,6 +41,192 @@
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function getScheduleRowFields(row) {
|
||||
return {
|
||||
mode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.mode) : null,
|
||||
startDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startDatetime) : null,
|
||||
endDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endDatetime) : null,
|
||||
startTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startTime) : null,
|
||||
endTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endTime) : null,
|
||||
daysJson: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.daysJson) : null
|
||||
};
|
||||
}
|
||||
|
||||
// Video duration helpers.
|
||||
function buildVideoDurationButtonMarkup(isPressed, enabled) {
|
||||
if (!enabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (isPressed ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '">' + (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL) + '</button>';
|
||||
}
|
||||
|
||||
function syncVideoDurationButtonState(button, isPressed, label) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.toggle('active', Boolean(isPressed));
|
||||
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
|
||||
button.textContent = label || (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL);
|
||||
}
|
||||
|
||||
// Slide picker helpers.
|
||||
function getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount) {
|
||||
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||
return query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||
}
|
||||
|
||||
return query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
}
|
||||
|
||||
function updateSlidePickerCardState(card, isAssigned, isCanvasMismatch) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||
card.disabled = isAssigned || isCanvasMismatch;
|
||||
card.classList.toggle('is-assigned', isAssigned);
|
||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||
card.classList.toggle('is-hidden', false);
|
||||
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function createElementWithClass(tagName, className, textContent) {
|
||||
var element = document.createElement(tagName);
|
||||
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (textContent !== undefined) {
|
||||
element.textContent = textContent;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function buildSlidePickerThumbPlaceholder() {
|
||||
var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder');
|
||||
var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon');
|
||||
var label = createElementWithClass('span', 'playlist-slide-picker-thumb-placeholder-label', PICKER_THUMB_EMPTY_LABEL);
|
||||
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(label);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function buildSlidePickerThumbImage(slide) {
|
||||
var image = createElementWithClass('img', 'playlist-slide-picker-thumb-image');
|
||||
|
||||
image.loading = 'lazy';
|
||||
image.alt = String(slide.title || 'Slide');
|
||||
image.src = String(slide.thumbnail_path || '');
|
||||
image.addEventListener('error', function () {
|
||||
if (image.parentNode) {
|
||||
image.parentNode.replaceChild(buildSlidePickerThumbPlaceholder(), image);
|
||||
}
|
||||
});
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function buildSlidePickerMedia(slide) {
|
||||
var media = createElementWithClass('div', PICKER_MEDIA_CLASS);
|
||||
var check = createElementWithClass('span', PICKER_CHECK_CLASS);
|
||||
var badge = createElementWithClass('span', PICKER_BADGE_CLASS, PICKER_ASSIGNED_BADGE_LABEL);
|
||||
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
|
||||
media.appendChild(slide && slide.thumbnail_path ? buildSlidePickerThumbImage(slide) : buildSlidePickerThumbPlaceholder());
|
||||
media.appendChild(check);
|
||||
media.appendChild(badge);
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
// Playlist row helpers.
|
||||
function getPlaylistSlideRow(target) {
|
||||
return target && target.closest ? target.closest('tr[data-playlist-slide-row]') : null;
|
||||
}
|
||||
|
||||
function getPlaylistSlideRowKey(row) {
|
||||
return String(row && row.getAttribute('data-row-key') || '');
|
||||
}
|
||||
|
||||
function isDurationControl(target) {
|
||||
return Boolean(target && target.closest && target.closest('.playlist-use-video-duration'));
|
||||
}
|
||||
|
||||
function getDurationPointerState(target, type) {
|
||||
var row = getPlaylistSlideRow(target);
|
||||
var pointerType = type === 'button' ? 'button' : 'input';
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: pointerType,
|
||||
rowKey: getPlaylistSlideRowKey(row)
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlaylistOrderCellMarkup() {
|
||||
return '' +
|
||||
'<td class="playlist-order-cell" data-label="Order">' +
|
||||
'<div class="playlist-order-cell-inner">' +
|
||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
||||
'<span class="playlist-order-number"></span>' +
|
||||
'</div>' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
|
||||
return '' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
function buildPlaylistScheduleCellMarkup(values) {
|
||||
return '' +
|
||||
'<td>' +
|
||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
|
||||
return '' +
|
||||
'<td><div class="playlist-duration-field">' +
|
||||
'<input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
||||
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
|
||||
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
|
||||
durationActionMarkup +
|
||||
'</div></td>';
|
||||
}
|
||||
|
||||
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
|
||||
return '' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
}
|
||||
|
||||
// Schedule modal wiring.
|
||||
function initPlaylistScheduleModal() {
|
||||
var dialog = document.getElementById('slide-schedule-dialog');
|
||||
var content = document.getElementById('slide-schedule-content');
|
||||
@@ -38,14 +244,9 @@
|
||||
|
||||
function collectScheduleParams(row, rowKey) {
|
||||
var params = new URLSearchParams();
|
||||
var scheduleFields = [
|
||||
'schedule_mode[]',
|
||||
'schedule_start_datetime[]',
|
||||
'schedule_end_datetime[]',
|
||||
'schedule_start_time[]',
|
||||
'schedule_end_time[]',
|
||||
'schedule_days_json[]'
|
||||
];
|
||||
var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) {
|
||||
return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, '');
|
||||
});
|
||||
|
||||
params.set('row_key', String(rowKey || ''));
|
||||
scheduleFields.forEach(function (fieldName) {
|
||||
@@ -113,6 +314,7 @@
|
||||
|
||||
}
|
||||
|
||||
// Schedule form behavior.
|
||||
function scheduleSummary(values) {
|
||||
var mode = String(values.schedule_mode || 'always');
|
||||
if (mode === 'dates') {
|
||||
@@ -214,10 +416,6 @@
|
||||
});
|
||||
select.addEventListener('change', clearScheduleValidity);
|
||||
|
||||
function notifyParentResize() {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
var mode = select.value;
|
||||
if (datesPanel) {
|
||||
@@ -234,9 +432,6 @@
|
||||
endTimeInput.value = DEFAULT_END_TIME;
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(function () {
|
||||
notifyParentResize();
|
||||
});
|
||||
}
|
||||
|
||||
select.addEventListener('change', updateVisibility);
|
||||
@@ -302,6 +497,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Playlist editor behavior.
|
||||
function initPlaylistEditStaging() {
|
||||
var tbody = document.getElementById('playlist-items-body');
|
||||
var form = document.getElementById('playlist-edit-form');
|
||||
@@ -334,13 +530,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getModalInstance() {
|
||||
if (!window.pulseModal) {
|
||||
return null;
|
||||
}
|
||||
return window.pulseModal.getOrCreate(addSlideModal);
|
||||
}
|
||||
|
||||
// Selection state helpers for the picker modal.
|
||||
function setCardSelected(card, selected) {
|
||||
if (!card) {
|
||||
return;
|
||||
@@ -373,50 +563,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function createPickerThumb(slide) {
|
||||
function createPlaceholder() {
|
||||
var placeholder = document.createElement('div');
|
||||
var icon = document.createElement('i');
|
||||
var label = document.createElement('span');
|
||||
|
||||
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
|
||||
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
label.className = 'playlist-slide-picker-thumb-placeholder-label';
|
||||
label.textContent = 'No thumbnail';
|
||||
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(label);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
if (slide && slide.thumbnail_path) {
|
||||
var image = document.createElement('img');
|
||||
image.className = 'playlist-slide-picker-thumb-image';
|
||||
image.loading = 'lazy';
|
||||
image.alt = String(slide.title || 'Slide');
|
||||
image.src = String(slide.thumbnail_path || '');
|
||||
image.addEventListener('error', function () {
|
||||
if (image.parentNode) {
|
||||
image.parentNode.replaceChild(createPlaceholder(), image);
|
||||
}
|
||||
});
|
||||
return image;
|
||||
}
|
||||
|
||||
return createPlaceholder();
|
||||
}
|
||||
|
||||
// Picker card rendering.
|
||||
function createPickerCard(slide) {
|
||||
var button = document.createElement('button');
|
||||
var media = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
var check = document.createElement('span');
|
||||
var badge = document.createElement('span');
|
||||
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||
|
||||
button.type = 'button';
|
||||
button.className = 'playlist-slide-picker-card';
|
||||
button.className = PICKER_CARD_CLASS;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||
button.setAttribute('data-search-text', searchText);
|
||||
@@ -427,18 +580,9 @@
|
||||
button.classList.add('is-assigned');
|
||||
}
|
||||
|
||||
media.className = 'playlist-slide-picker-media';
|
||||
media.appendChild(createPickerThumb(slide));
|
||||
check.className = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(check);
|
||||
|
||||
badge.className = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||
badge.textContent = 'Already in playlist';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(badge);
|
||||
|
||||
title.className = 'playlist-slide-picker-title';
|
||||
var media = buildSlidePickerMedia(slide);
|
||||
var title = document.createElement('div');
|
||||
title.className = PICKER_TITLE_CLASS;
|
||||
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||
|
||||
button.appendChild(media);
|
||||
@@ -447,6 +591,7 @@
|
||||
return button;
|
||||
}
|
||||
|
||||
// Picker filtering and modal controls.
|
||||
function setShowAssignedState(showAssignedSlides) {
|
||||
if (!addSlideShowAssigned) {
|
||||
return;
|
||||
@@ -492,11 +637,7 @@
|
||||
});
|
||||
|
||||
if (addSlideEmpty) {
|
||||
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||
addSlideEmpty.textContent = query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||
} else {
|
||||
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
}
|
||||
addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount);
|
||||
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||
}
|
||||
|
||||
@@ -615,24 +756,19 @@
|
||||
}
|
||||
|
||||
function scheduleSummaryForRow(row) {
|
||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
||||
var mode = String(modeInput ? modeInput.value : 'always');
|
||||
var days = formatDays(daysJson && daysJson.value ? daysJson.value : '[]');
|
||||
var fields = getScheduleRowFields(row);
|
||||
var mode = String(fields.mode ? fields.mode.value : 'always');
|
||||
var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]');
|
||||
|
||||
if (mode === 'dates') {
|
||||
if (startDatetime && endDatetime && startDatetime.value && endDatetime.value) {
|
||||
return 'Dates: ' + startDatetime.value.replace('T', ' ') + ' to ' + endDatetime.value.replace('T', ' ');
|
||||
if (fields.startDatetime && fields.endDatetime && fields.startDatetime.value && fields.endDatetime.value) {
|
||||
return 'Dates: ' + fields.startDatetime.value.replace('T', ' ') + ' to ' + fields.endDatetime.value.replace('T', ' ');
|
||||
}
|
||||
return 'Dates: not set';
|
||||
}
|
||||
if (mode === 'times') {
|
||||
if (days && startTime && endTime && startTime.value && endTime.value) {
|
||||
return 'Times: ' + days + ' ' + startTime.value.slice(0, 5) + '-' + endTime.value.slice(0, 5);
|
||||
if (days && fields.startTime && fields.endTime && fields.startTime.value && fields.endTime.value) {
|
||||
return 'Times: ' + days + ' ' + fields.startTime.value.slice(0, 5) + '-' + fields.endTime.value.slice(0, 5);
|
||||
}
|
||||
return 'Times: not set';
|
||||
}
|
||||
@@ -670,14 +806,7 @@
|
||||
|
||||
slide.isAssigned = isAssigned;
|
||||
|
||||
if (card) {
|
||||
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||
card.disabled = isAssigned || isCanvasMismatch;
|
||||
card.classList.toggle('is-assigned', isAssigned);
|
||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||
card.classList.toggle('is-hidden', false);
|
||||
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||
}
|
||||
updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
|
||||
if (isAssigned) {
|
||||
slidePickerSelection.delete(slideId);
|
||||
if (card) {
|
||||
@@ -745,31 +874,26 @@
|
||||
}
|
||||
|
||||
function setRowSchedule(row, values) {
|
||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
||||
var fields = getScheduleRowFields(row);
|
||||
var summary = row.querySelector('.playlist-schedule-summary');
|
||||
|
||||
if (modeInput) {
|
||||
modeInput.value = values.schedule_mode || 'always';
|
||||
if (fields.mode) {
|
||||
fields.mode.value = values.schedule_mode || 'always';
|
||||
}
|
||||
if (startDatetime) {
|
||||
startDatetime.value = values.schedule_start_datetime || '';
|
||||
if (fields.startDatetime) {
|
||||
fields.startDatetime.value = values.schedule_start_datetime || '';
|
||||
}
|
||||
if (endDatetime) {
|
||||
endDatetime.value = values.schedule_end_datetime || '';
|
||||
if (fields.endDatetime) {
|
||||
fields.endDatetime.value = values.schedule_end_datetime || '';
|
||||
}
|
||||
if (startTime) {
|
||||
startTime.value = values.schedule_start_time || '';
|
||||
if (fields.startTime) {
|
||||
fields.startTime.value = values.schedule_start_time || '';
|
||||
}
|
||||
if (endTime) {
|
||||
endTime.value = values.schedule_end_time || '';
|
||||
if (fields.endTime) {
|
||||
fields.endTime.value = values.schedule_end_time || '';
|
||||
}
|
||||
if (daysJson) {
|
||||
daysJson.value = values.schedule_days_json || '[]';
|
||||
if (fields.daysJson) {
|
||||
fields.daysJson.value = values.schedule_days_json || '[]';
|
||||
}
|
||||
if (summary) {
|
||||
summary.textContent = values.summary || scheduleSummaryForRow(row);
|
||||
@@ -799,40 +923,16 @@
|
||||
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
|
||||
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
|
||||
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
|
||||
var durationActionMarkup = Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
|
||||
? '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (values.useVideoDuration ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (values.useVideoDuration ? 'true' : 'false') + '">' + (values.useVideoDuration ? 'Use Video Duration' : 'Use video duration') + '</button>'
|
||||
: '';
|
||||
var durationActionMarkup = buildVideoDurationButtonMarkup(
|
||||
Boolean(values.useVideoDuration),
|
||||
Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
|
||||
);
|
||||
row.innerHTML = '' +
|
||||
'<td class="playlist-order-cell" data-label="Order">' +
|
||||
'<div class="playlist-order-cell-inner">' +
|
||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
||||
'<span class="playlist-order-number"></span>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
||||
'<td>' +
|
||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||
'</td>' +
|
||||
'<td><div class="playlist-duration-field"><input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
||||
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
|
||||
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
|
||||
durationActionMarkup +
|
||||
'</div></td>' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
buildPlaylistOrderCellMarkup() +
|
||||
buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) +
|
||||
buildPlaylistScheduleCellMarkup(values) +
|
||||
buildPlaylistDurationCellMarkup(values, durationActionMarkup) +
|
||||
buildPlaylistActionsCellMarkup(playlistId, rowKey);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -919,11 +1019,7 @@
|
||||
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.classList.toggle('active', Boolean(isPressed));
|
||||
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
|
||||
button.textContent = isPressed ? 'Use Video Duration' : 'Use video duration';
|
||||
}
|
||||
syncVideoDurationButtonState(button, isPressed);
|
||||
|
||||
if (input) {
|
||||
if (!isPressed && row) {
|
||||
@@ -997,7 +1093,7 @@
|
||||
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Loading...';
|
||||
button.textContent = VIDEO_DURATION_LOADING_LABEL;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1018,7 +1114,7 @@
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = button.getAttribute('aria-pressed') === 'true' ? 'Use Video Duration' : 'Use video duration';
|
||||
syncVideoDurationButtonState(button, button.getAttribute('aria-pressed') === 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1032,7 +1128,7 @@
|
||||
var removeButton = event.target.closest('[data-playlist-remove-row]');
|
||||
var scheduleButton = event.target.closest('[data-schedule-config]');
|
||||
var durationButton = event.target.closest('[data-use-video-duration]');
|
||||
var row = event.target.closest('tr[data-playlist-slide-row]');
|
||||
var row = getPlaylistSlideRow(event.target);
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
@@ -1048,13 +1144,13 @@
|
||||
if (scheduleButton) {
|
||||
event.preventDefault();
|
||||
if (typeof window.openScheduleModal === 'function') {
|
||||
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
|
||||
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(getPlaylistSlideRowKey(row)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationButton) {
|
||||
var rowKey = row.getAttribute('data-row-key');
|
||||
var rowKey = getPlaylistSlideRowKey(row);
|
||||
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
|
||||
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
|
||||
|
||||
@@ -1074,29 +1170,23 @@
|
||||
});
|
||||
|
||||
tbody.addEventListener('mousedown', function (event) {
|
||||
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null;
|
||||
var row = getPlaylistSlideRow(event.target);
|
||||
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
|
||||
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
||||
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null;
|
||||
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
||||
|
||||
if (row && !dragHandle) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (durationInput) {
|
||||
lastDurationPointerDown = {
|
||||
type: 'input',
|
||||
rowKey: String(event.target.closest('tr[data-playlist-slide-row]') && event.target.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
|
||||
};
|
||||
lastDurationPointerDown = getDurationPointerState(event.target, 'input');
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationToggle) {
|
||||
lastDurationPointerDown = {
|
||||
type: 'button',
|
||||
rowKey: String(durationToggle.closest('tr[data-playlist-slide-row]') && durationToggle.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
|
||||
};
|
||||
lastDurationPointerDown = getDurationPointerState(durationToggle, 'button');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1105,22 +1195,16 @@
|
||||
|
||||
tbody.addEventListener('mouseup', function (event) {
|
||||
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
||||
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null;
|
||||
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null;
|
||||
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
||||
var row = getPlaylistSlideRow(event.target);
|
||||
|
||||
if (durationInput) {
|
||||
lastDurationPointerUp = {
|
||||
type: 'input',
|
||||
rowKey: String(row && row.getAttribute('data-row-key') || '')
|
||||
};
|
||||
lastDurationPointerUp = getDurationPointerState(row, 'input');
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationToggle) {
|
||||
lastDurationPointerUp = {
|
||||
type: 'button',
|
||||
rowKey: String(row && row.getAttribute('data-row-key') || '')
|
||||
};
|
||||
lastDurationPointerUp = getDurationPointerState(row, 'button');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1129,7 +1213,7 @@
|
||||
|
||||
tbody.addEventListener('input', function (event) {
|
||||
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
|
||||
var editedRow = event.target.closest('tr[data-playlist-slide-row]');
|
||||
var editedRow = getPlaylistSlideRow(event.target);
|
||||
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
|
||||
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
|
||||
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
(function () {
|
||||
var dataElement = document.getElementById('template-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
var utils = window.templateDesignerUtils || {};
|
||||
|
||||
var templateData = {};
|
||||
try {
|
||||
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
|
||||
} catch (_error) {
|
||||
templateData = {};
|
||||
}
|
||||
|
||||
var existingRegions = Array.isArray(templateData.regions)
|
||||
? templateData.regions
|
||||
: (Array.isArray(templateData) ? templateData : []);
|
||||
var stage = document.getElementById('designer-stage');
|
||||
var overlay = document.getElementById('designer-overlay');
|
||||
var regionList = document.getElementById('region-list');
|
||||
var regionSelect = document.getElementById('region-select');
|
||||
var canvasSizeSelect = document.getElementById('canvas-size-select');
|
||||
var canvasSizeIdInput = document.getElementById('canvas-size-id');
|
||||
var canvasSizeSummary = document.getElementById('canvas-size-summary');
|
||||
var canvasWidthInput = document.getElementById('canvas-width');
|
||||
var canvasHeightInput = document.getElementById('canvas-height');
|
||||
var backgroundInput = document.getElementById('background-image');
|
||||
var backgroundColorInput = document.getElementById('background-color');
|
||||
var backgroundPreview = document.getElementById('background-preview');
|
||||
var backgroundEmpty = document.getElementById('background-empty');
|
||||
var removeBackgroundButton = document.getElementById('remove-background-image');
|
||||
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
|
||||
var addRegionButton = document.getElementById('add-region-button');
|
||||
var regionAddModal = document.getElementById('region-add-modal');
|
||||
var regionCardTemplate = document.getElementById('region-card-template');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var templateForm = document.getElementById('template-form');
|
||||
var draft = null;
|
||||
var selectedIndex = -1;
|
||||
var overlayRenderFrame = 0;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function valueOrDefault(value, fallback) {
|
||||
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
|
||||
}
|
||||
|
||||
function getCanvasSize() {
|
||||
return {
|
||||
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
|
||||
height: Math.max(1, Number(canvasHeightInput.value || 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getCards() {
|
||||
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
|
||||
}
|
||||
|
||||
function cardAt(index) {
|
||||
return getCards()[index] || null;
|
||||
}
|
||||
|
||||
function getRegionName(card) {
|
||||
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
|
||||
}
|
||||
|
||||
function syncRegionIdentity(card, value) {
|
||||
if (utils.syncRegionIdentity) {
|
||||
utils.syncRegionIdentity(card, value);
|
||||
return;
|
||||
}
|
||||
var next = String(value || '').trim();
|
||||
card.querySelector('[name="region_name[]"]').value = next;
|
||||
card.querySelector('[name="region_key[]"]').value = next;
|
||||
card.querySelector('[name="region_label[]"]').value = next;
|
||||
}
|
||||
|
||||
function validateRegionNames() {
|
||||
var cards = getCards();
|
||||
var names = {};
|
||||
var hasDuplicate = false;
|
||||
|
||||
cards.forEach(function (card) {
|
||||
var input = card.querySelector('[name="region_name[]"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = String(input.value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
input.setCustomValidity('Region name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!names[normalized]) {
|
||||
names[normalized] = [];
|
||||
}
|
||||
names[normalized].push(input);
|
||||
});
|
||||
|
||||
Object.keys(names).forEach(function (key) {
|
||||
var inputs = names[key];
|
||||
if (inputs.length > 1) {
|
||||
hasDuplicate = true;
|
||||
inputs.forEach(function (input) {
|
||||
input.setCustomValidity('Region names must be unique on this template.');
|
||||
});
|
||||
} else {
|
||||
inputs[0].setCustomValidity('');
|
||||
}
|
||||
});
|
||||
|
||||
return !hasDuplicate;
|
||||
}
|
||||
|
||||
function readCard(card) {
|
||||
return utils.readCard ? utils.readCard(card) : {
|
||||
region_key: getRegionName(card),
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
|
||||
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function writeCard(card, values) {
|
||||
if (utils.writeCard) {
|
||||
utils.writeCard(card, values);
|
||||
return;
|
||||
}
|
||||
if (values.region_name !== undefined) {
|
||||
syncRegionIdentity(card, values.region_name);
|
||||
} else if (values.region_key !== undefined) {
|
||||
syncRegionIdentity(card, values.region_key);
|
||||
} else if (values.label !== undefined) {
|
||||
syncRegionIdentity(card, values.label);
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
|
||||
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
|
||||
}
|
||||
|
||||
function getOverlayRect() {
|
||||
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
|
||||
}
|
||||
|
||||
function toCanvasPoint(event) {
|
||||
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
|
||||
function canvasRectToPixels(region) {
|
||||
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
}
|
||||
|
||||
function updateAspectRatio() {
|
||||
var size = getCanvasSize();
|
||||
stage.style.aspectRatio = size.width + ' / ' + size.height;
|
||||
}
|
||||
|
||||
function updateCanvasSizeSummary() {
|
||||
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
|
||||
canvasSizeSummary.textContent = option ? option.textContent : '';
|
||||
}
|
||||
|
||||
function updateCanvasSizeLock() {
|
||||
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
|
||||
var locked = lockOnExistingTemplate && getCards().length > 0;
|
||||
canvasSizeSelect.disabled = locked;
|
||||
canvasSizeSummary.classList.toggle('is-locked', locked);
|
||||
}
|
||||
|
||||
function syncCanvasSizeSelection() {
|
||||
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
if (canvasSizeIdInput) {
|
||||
canvasSizeIdInput.value = option.value;
|
||||
}
|
||||
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
|
||||
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
|
||||
updateAspectRatio();
|
||||
updateCanvasSizeSummary();
|
||||
}
|
||||
|
||||
function updateBackgroundPreview(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (removeBackgroundFlag) {
|
||||
removeBackgroundFlag.checked = false;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
backgroundPreview.src = reader.result;
|
||||
backgroundPreview.style.display = 'block';
|
||||
backgroundEmpty.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function updateStageBackgroundColor() {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
var chip = card.querySelector('[data-region-chip]');
|
||||
var title = card.querySelector('[data-region-title]');
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
|
||||
var regionTypeInput = card.querySelector('[name="region_type[]"]');
|
||||
var regionKeyInput = card.querySelector('[name="region_key[]"]');
|
||||
var regionLabelInput = card.querySelector('[name="region_label[]"]');
|
||||
|
||||
if (title) {
|
||||
title.textContent = region.label || region.region_key || 'Region';
|
||||
}
|
||||
if (chip) {
|
||||
chip.textContent = getRegionChipLabel(region.region_type);
|
||||
}
|
||||
if (nameInput) {
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
}
|
||||
if (regionKeyInput) {
|
||||
regionKeyInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (regionLabelInput) {
|
||||
regionLabelInput.value = region.label || region.region_key || '';
|
||||
}
|
||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
|
||||
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
|
||||
}
|
||||
|
||||
function updateRegionLabel(card) {
|
||||
var label = getRegionName(card) || 'Region';
|
||||
var cards = getCards();
|
||||
var index = cards.indexOf(card);
|
||||
var title = card.querySelector('.template-field-head strong');
|
||||
if (title) {
|
||||
title.textContent = label;
|
||||
}
|
||||
if (index >= 0 && regionSelect.options[index]) {
|
||||
regionSelect.options[index].textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRegionCard(region) {
|
||||
var card;
|
||||
if (regionCardTemplate && regionCardTemplate.content) {
|
||||
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
} else {
|
||||
card = document.createElement('div');
|
||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
||||
}
|
||||
populateRegionCard(card, region);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
updateRegionLabel(card);
|
||||
validateRegionNames();
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
||||
return;
|
||||
}
|
||||
setSelected(getCards().indexOf(card));
|
||||
});
|
||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
card.remove();
|
||||
if (!getCards().length) {
|
||||
selectedIndex = -1;
|
||||
} else if (selectedIndex >= getCards().length) {
|
||||
selectedIndex = getCards().length - 1;
|
||||
}
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderRegionList(initialRegions) {
|
||||
regionList.innerHTML = '';
|
||||
initialRegions.forEach(function (region) {
|
||||
regionList.appendChild(makeRegionCard(region));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRegionSidebar() {
|
||||
var cards = getCards();
|
||||
regionSelect.innerHTML = '';
|
||||
if (!cards.length) {
|
||||
regionSelect.disabled = true;
|
||||
regionList.innerHTML = '';
|
||||
updateCanvasSizeLock();
|
||||
return;
|
||||
}
|
||||
regionSelect.disabled = false;
|
||||
if (selectedIndex < 0 || selectedIndex >= cards.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
cards.forEach(function (card, index) {
|
||||
var option = document.createElement('option');
|
||||
option.value = String(index);
|
||||
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
|
||||
if (index === selectedIndex) {
|
||||
option.selected = true;
|
||||
}
|
||||
regionSelect.appendChild(option);
|
||||
card.hidden = index !== selectedIndex;
|
||||
});
|
||||
regionSelect.value = String(selectedIndex);
|
||||
updateCanvasSizeLock();
|
||||
validateRegionNames();
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
var cards = getCards();
|
||||
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
|
||||
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
|
||||
var regions = cards.map(readCard);
|
||||
regionsJsonInput.value = JSON.stringify(regions);
|
||||
overlay.innerHTML = regions.map(function (region, index) {
|
||||
var box = canvasRectToPixels(region);
|
||||
var selected = index === selectedNow ? ' selected' : '';
|
||||
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
|
||||
}).join('');
|
||||
if (draft) {
|
||||
var rect = getOverlayRect();
|
||||
var size = getCanvasSize();
|
||||
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
|
||||
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function requestOverlayRender() {
|
||||
if (overlayRenderFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlayRenderFrame = window.requestAnimationFrame(function () {
|
||||
overlayRenderFrame = 0;
|
||||
renderOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
updateAspectRatio();
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function setSelected(index) {
|
||||
var cards = getCards();
|
||||
if (!cards.length) {
|
||||
selectedIndex = -1;
|
||||
} else if (index < 0) {
|
||||
selectedIndex = 0;
|
||||
} else {
|
||||
selectedIndex = clamp(index, 0, cards.length - 1);
|
||||
}
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function addRegion(region) {
|
||||
var hint = regionList.querySelector('.muted');
|
||||
if (hint) {
|
||||
hint.remove();
|
||||
}
|
||||
regionList.appendChild(makeRegionCard(region));
|
||||
setSelected(getCards().length - 1);
|
||||
}
|
||||
|
||||
function openAddRegionModal() {
|
||||
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
|
||||
return;
|
||||
}
|
||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
|
||||
}
|
||||
|
||||
function createDefaultRegion(type) {
|
||||
var count = getCards().length + 1;
|
||||
var name = 'region_' + count;
|
||||
return {
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
|
||||
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
|
||||
z_index: 1
|
||||
};
|
||||
}
|
||||
|
||||
function clampRegion(region) {
|
||||
var size = getCanvasSize();
|
||||
var minSize = 12;
|
||||
var x = clamp(region.x, 0, size.width - minSize);
|
||||
var y = clamp(region.y, 0, size.height - minSize);
|
||||
var width = Math.max(minSize, region.width);
|
||||
var height = Math.max(minSize, region.height);
|
||||
if (x + width > size.width) {
|
||||
width = size.width - x;
|
||||
}
|
||||
if (y + height > size.height) {
|
||||
height = size.height - y;
|
||||
}
|
||||
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
|
||||
}
|
||||
|
||||
function startDraw(event) {
|
||||
var start = toCanvasPoint(event);
|
||||
draft = { start: start, end: start };
|
||||
renderOverlay();
|
||||
function moveHandler(moveEvent) {
|
||||
draft.end = toCanvasPoint(moveEvent);
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler(upEvent) {
|
||||
draft.end = toCanvasPoint(upEvent);
|
||||
var width = Math.abs(draft.end.x - draft.start.x);
|
||||
var height = Math.abs(draft.end.y - draft.start.y);
|
||||
if (width >= 8 && height >= 8) {
|
||||
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
|
||||
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
|
||||
}
|
||||
draft = null;
|
||||
requestOverlayRender();
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
}
|
||||
|
||||
function startMove(index, event) {
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
function moveHandler(moveEvent) {
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
}
|
||||
|
||||
function resizeFromHandle(index, dir, event) {
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
function moveHandler(moveEvent) {
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
|
||||
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
|
||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
|
||||
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
|
||||
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
|
||||
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
|
||||
next = clampRegion(next);
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
}
|
||||
|
||||
if (addRegionButton && regionAddModal) {
|
||||
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
|
||||
addRegionButton.addEventListener('click', function () {
|
||||
openAddRegionModal();
|
||||
});
|
||||
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var regionType = button.getAttribute('data-add-region-type');
|
||||
addRegion(createDefaultRegion(regionType));
|
||||
if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
backgroundInput.addEventListener('change', function () {
|
||||
var file = backgroundInput.files && backgroundInput.files[0];
|
||||
if (file) {
|
||||
updateBackgroundPreview(file);
|
||||
}
|
||||
});
|
||||
if (removeBackgroundButton && removeBackgroundFlag) {
|
||||
removeBackgroundButton.addEventListener('click', function () {
|
||||
removeBackgroundFlag.checked = true;
|
||||
backgroundInput.value = '';
|
||||
backgroundPreview.removeAttribute('src');
|
||||
backgroundPreview.style.display = 'none';
|
||||
backgroundEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
if (backgroundColorInput) {
|
||||
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
||||
}
|
||||
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
|
||||
canvasWidthInput.addEventListener('input', render);
|
||||
canvasHeightInput.addEventListener('input', render);
|
||||
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
|
||||
overlay.addEventListener('mousedown', function (event) {
|
||||
var rect = event.target.closest('.designer-rect');
|
||||
if (rect) {
|
||||
var index = Number(rect.getAttribute('data-index'));
|
||||
var handle = event.target.closest('.resize-handle');
|
||||
event.preventDefault();
|
||||
setSelected(index);
|
||||
if (handle) {
|
||||
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
|
||||
} else {
|
||||
startMove(index, event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setSelected(-1);
|
||||
startDraw(event);
|
||||
});
|
||||
if (templateForm) {
|
||||
templateForm.addEventListener('formdata', function (event) {
|
||||
if (!validateRegionNames()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
syncCanvasSizeSelection();
|
||||
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
|
||||
});
|
||||
|
||||
templateForm.addEventListener('submit', function () {
|
||||
if (!validateRegionNames()) {
|
||||
return;
|
||||
}
|
||||
syncCanvasSizeSelection();
|
||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
||||
});
|
||||
}
|
||||
|
||||
renderRegionList(existingRegions);
|
||||
syncCanvasSizeSelection();
|
||||
updateStageBackgroundColor();
|
||||
render();
|
||||
})();
|
||||
@@ -36,10 +36,26 @@
|
||||
var regionCardTemplate = document.getElementById('region-card-template');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var templateForm = document.getElementById('template-form');
|
||||
var templateRegionUsageElement = document.getElementById('template-region-usage');
|
||||
var regionUsageSet = new Set();
|
||||
var draft = null;
|
||||
var selectedIndex = -1;
|
||||
var overlayRenderFrame = 0;
|
||||
|
||||
try {
|
||||
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
||||
if (Array.isArray(regionUsageData)) {
|
||||
regionUsageData.forEach(function (regionKey) {
|
||||
var normalized = String(regionKey || '').trim();
|
||||
if (normalized) {
|
||||
regionUsageSet.add(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
regionUsageSet = new Set();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
@@ -357,6 +373,21 @@
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
|
||||
function setRegionRemovalState(card) {
|
||||
var removeButton = card.querySelector('[data-region-remove-button]');
|
||||
if (!removeButton) {
|
||||
return;
|
||||
}
|
||||
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
|
||||
var regionDeletionLocked = regionUsageSet.has(regionKey);
|
||||
removeButton.disabled = regionDeletionLocked;
|
||||
if (regionDeletionLocked) {
|
||||
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
|
||||
} else {
|
||||
removeButton.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
|
||||
function updateRegionLabel(card) {
|
||||
var label = getRegionName(card) || 'Region';
|
||||
var cards = getCards();
|
||||
@@ -379,6 +410,8 @@
|
||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
||||
}
|
||||
populateRegionCard(card, region);
|
||||
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
|
||||
setRegionRemovalState(card);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
@@ -415,12 +448,12 @@
|
||||
});
|
||||
}
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
||||
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
|
||||
return;
|
||||
}
|
||||
setSelected(getCards().indexOf(card));
|
||||
});
|
||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
||||
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
card.remove();
|
||||
if (!getCards().length) {
|
||||
@@ -523,7 +556,9 @@
|
||||
if (hint) {
|
||||
hint.remove();
|
||||
}
|
||||
regionList.appendChild(makeRegionCard(region));
|
||||
var card = makeRegionCard(region);
|
||||
setRegionRemovalState(card);
|
||||
regionList.appendChild(card);
|
||||
setSelected(getCards().length - 1);
|
||||
}
|
||||
|
||||
@@ -600,8 +635,12 @@
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
||||
var size = getCanvasSize();
|
||||
var maxX = Math.max(0, size.width - startRegion.width);
|
||||
var maxY = Math.max(0, size.height - startRegion.height);
|
||||
var nextX = clamp(startRegion.x + dx, 0, maxX);
|
||||
var nextY = clamp(startRegion.y + dy, 0, maxY);
|
||||
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
@@ -617,6 +656,24 @@
|
||||
var startRegion = readCard(cardAt(index));
|
||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
var canvasSize = getCanvasSize();
|
||||
var minSize = 12;
|
||||
|
||||
function clampResizeDelta(dx, dy) {
|
||||
if (dir.indexOf('w') !== -1) {
|
||||
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
|
||||
}
|
||||
if (dir.indexOf('e') !== -1) {
|
||||
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
|
||||
}
|
||||
if (dir.indexOf('n') !== -1) {
|
||||
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
|
||||
}
|
||||
if (dir.indexOf('s') !== -1) {
|
||||
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
|
||||
}
|
||||
return { dx: dx, dy: dy };
|
||||
}
|
||||
|
||||
function fitFromWidth(width) {
|
||||
var nextWidth = Math.max(12, Math.round(width));
|
||||
@@ -638,6 +695,9 @@
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var constrainedDelta = clampResizeDelta(dx, dy);
|
||||
dx = constrainedDelta.dx;
|
||||
dy = constrainedDelta.dy;
|
||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||
if (aspect) {
|
||||
if (dir === 'e') {
|
||||
@@ -748,7 +808,6 @@
|
||||
}
|
||||
event.preventDefault();
|
||||
setSelected(-1);
|
||||
startDraw(event);
|
||||
});
|
||||
if (templateForm) {
|
||||
templateForm.addEventListener('formdata', function (event) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
window.webUiHelpers = {
|
||||
escapeHtml: escapeHtml,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getClientRowKey: getClientRowKey,
|
||||
getClientDisplayName: getClientDisplayName,
|
||||
setButtonVariant: setButtonVariant,
|
||||
normalizeDisplayIp: normalizeDisplayIp
|
||||
};
|
||||
}());
|
||||
@@ -104,6 +104,55 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplateSlideCount(templateId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchTemplateRegionUsage(template) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
|
||||
const usage = new Set();
|
||||
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
|
||||
.map((region) => String(region && region.region_key || '').trim())
|
||||
.filter(Boolean));
|
||||
|
||||
if (!regionKeys.size || !slides.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
slides.forEach((slide) => {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return;
|
||||
}
|
||||
regionKeys.forEach((regionKey) => {
|
||||
if (Object.prototype.hasOwnProperty.call(content, regionKey)) {
|
||||
usage.add(regionKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(usage);
|
||||
}
|
||||
|
||||
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
|
||||
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
|
||||
return (Array.isArray(existingRegions) ? existingRegions : [])
|
||||
.map((region) => String(region && region.region_key || '').trim())
|
||||
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
||||
}
|
||||
|
||||
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||
if (!removedRegionKeys.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const usedRegionKeys = Array.isArray(template && template.region_usage) ? template.region_usage : [];
|
||||
const blockedKeys = removedRegionKeys.filter((regionKey) => usedRegionKeys.includes(regionKey));
|
||||
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
|
||||
}
|
||||
|
||||
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
@@ -423,6 +472,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
@@ -436,11 +486,16 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
|
||||
if (regionDeleteBlockMessage) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
|
||||
canvas_size_height: 1080,
|
||||
background_color: '#111111',
|
||||
background_image_path: '',
|
||||
region_usage: [],
|
||||
regions: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
name: template.name,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
slideCount: template.slide_count !== undefined
|
||||
? Number(template.slide_count) || 0
|
||||
: 0,
|
||||
regionCount: template.region_count !== undefined
|
||||
? Number(template.region_count) || 0
|
||||
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html data-bs-theme="dark" style="color-scheme: dark;">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -82,6 +82,7 @@
|
||||
{{/if}}
|
||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||
<script src="/assets/js/admin/toast.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/admin/system-status.js"></script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html data-bs-theme="dark" style="color-scheme: dark;">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
@@ -17,7 +17,7 @@
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</head>
|
||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}">
|
||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
|
||||
{{#if errorShell}}
|
||||
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
|
||||
{{{body}}}
|
||||
@@ -284,6 +284,7 @@
|
||||
</dialog>
|
||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||
<script src="/assets/js/admin/toast.js"></script>
|
||||
<script src="/assets/js/table-sort.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form">
|
||||
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}">
|
||||
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
||||
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
||||
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
||||
<input type="hidden" id="canvas-size-summary" />
|
||||
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
||||
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
||||
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
|
||||
|
||||
<div class="template-designer-layout">
|
||||
<div class="card card-outline card-secondary admin-form-card mb-3">
|
||||
@@ -30,7 +31,7 @@
|
||||
</div>
|
||||
<div class="template-designer-sidebar">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-body d-grid gap-2">
|
||||
<div class="card-body d-grid">
|
||||
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
|
||||
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
@@ -114,20 +115,20 @@
|
||||
<div class="modal-body">
|
||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<template id="region-card-template">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item">
|
||||
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
||||
<div class="card-body p-3 d-grid gap-3">
|
||||
<div class="region-field-grid region-field-grid--identity">
|
||||
@@ -146,8 +147,8 @@
|
||||
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
||||
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
||||
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
||||
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button>Remove</button></label>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
|
||||
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
|
||||
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
||||
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
||||
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
||||
<input type="hidden" id="canvas-size-summary" />
|
||||
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
||||
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
||||
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
|
||||
|
||||
<div class="template-designer-layout">
|
||||
<div class="card card-outline card-secondary admin-form-card mb-3">
|
||||
@@ -30,7 +31,7 @@
|
||||
</div>
|
||||
<div class="template-designer-sidebar">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-body d-grid gap-2">
|
||||
<div class="card-body d-grid">
|
||||
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
|
||||
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
@@ -121,20 +122,20 @@
|
||||
<div class="modal-body">
|
||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
|
||||
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<template id="region-card-template">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
|
||||
<div class="card card-outline card-secondary admin-form-card region-item">
|
||||
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
||||
<div class="card-body p-3 d-grid gap-3">
|
||||
<div class="region-field-grid region-field-grid--identity">
|
||||
@@ -153,8 +154,8 @@
|
||||
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
||||
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
||||
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
||||
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button disabled>Remove</button></label>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<th>Name</th>
|
||||
<th>Canvas</th>
|
||||
<th>Regions</th>
|
||||
<th>Slides used by</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -35,6 +36,7 @@
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
|
||||
<td data-label="Regions">{{regionCount}}</td>
|
||||
<td data-label="Slides used by">{{slideCount}}</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}}
|
||||
<div class="actions">
|
||||
@@ -54,7 +56,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No templates yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No templates yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user