diff --git a/CHANGELOG.md b/CHANGELOG.md
index 592c9eb..c97073b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 7ab7c14..b40b4ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index ff8d35a..ab90940 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/data/admin.js b/src/data/admin.js
index 2bc441f..35e895a 100644
--- a/src/data/admin.js
+++ b/src/data/admin.js
@@ -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`,
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index a9dc98b..acd6867 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -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;
diff --git a/src/web/public/js/admin-page.js b/src/web/public/js/admin-page.js
deleted file mode 100644
index 74969cc..0000000
--- a/src/web/public/js/admin-page.js
+++ /dev/null
@@ -1,599 +0,0 @@
-(function () {
-
- function escapeHtml(value) {
- return String(value ?? '')
- .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;
-}());
diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js
index f9b845d..998c0f3 100644
--- a/src/web/public/js/admin/admin-page.js
+++ b/src/web/public/js/admin/admin-page.js
@@ -1,84 +1,11 @@
(function () {
-
- function escapeHtml(value) {
- return String(value ?? '')
- .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) {
diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js
index 634eb02..e89c78c 100644
--- a/src/web/public/js/dashboard/dashboard-page.js
+++ b/src/web/public/js/dashboard/dashboard-page.js
@@ -1,83 +1,11 @@
(function () {
- function escapeHtml(value) {
- return String(value ?? '')
- .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);
diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js
index c8cfe23..3d7b371 100644
--- a/src/web/public/js/playlists/playlist-schedule.js
+++ b/src/web/public/js/playlists/playlist-schedule.js
@@ -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 '';
+ }
+
+ 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 '' +
+ '
' +
+ '' +
+ ' | ';
+ }
+
+ function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
+ return '' +
+ '' +
+ '' +
+ ' ' + thumbnailMarkup + ' ' +
+ ' ' + values.title + ' ' +
+ ' ' +
+ '' +
+ ' | ';
+ }
+
+ function buildPlaylistScheduleCellMarkup(values) {
+ return '' +
+ '' +
+ ' ' + values.summary + ' ' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ ' | ';
+ }
+
+ function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
+ return '' +
+ '' +
+ '' +
+ '' +
+ (values.useVideoDuration ? '' : '') +
+ durationActionMarkup +
+ ' | ';
+ }
+
+ function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
+ return '' +
+ '' +
+ '' +
+ '' +
+ ' | ';
+ }
+
+ // 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'
- ? ''
- : '';
+ var durationActionMarkup = buildVideoDurationButtonMarkup(
+ Boolean(values.useVideoDuration),
+ Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
+ );
row.innerHTML = '' +
- '' +
- '' +
- ' | ' +
- '' +
- '' +
- ' ' + thumbnailMarkup + ' ' +
- ' ' + values.title + ' ' +
- ' ' +
- ' | ' +
- '' +
- ' ' + values.summary + ' ' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
- ' | ' +
- '' +
- '' +
- (values.useVideoDuration ? '' : '') +
- durationActionMarkup +
- ' | ' +
- '' +
- '' +
- '' +
- ' | ';
+ 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');
diff --git a/src/web/public/js/template-designer.js b/src/web/public/js/template-designer.js
deleted file mode 100644
index 238d58e..0000000
--- a/src/web/public/js/template-designer.js
+++ /dev/null
@@ -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 '' + escapeHtml(region.label || region.region_key || 'Region') + '
';
- }).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 += '';
- }
- }
-
- 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();
-})();
diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js
index fb14ef1..d957243 100644
--- a/src/web/public/js/templates/template-designer.js
+++ b/src/web/public/js/templates/template-designer.js
@@ -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) {
diff --git a/src/web/public/js/web-ui-helpers.js b/src/web/public/js/web-ui-helpers.js
new file mode 100644
index 0000000..6ca2ef8
--- /dev/null
+++ b/src/web/public/js/web-ui-helpers.js
@@ -0,0 +1,94 @@
+(function () {
+ function escapeHtml(value) {
+ return String(value ?? '')
+ .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
+ };
+}());
\ No newline at end of file
diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js
index 4545685..a1a593c 100644
--- a/src/web/routes/admin/content.js
+++ b/src/web/routes/admin/content.js
@@ -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);
diff --git a/src/web/routes/signage/templates/add.js b/src/web/routes/signage/templates/add.js
index 69d816e..55d0412 100644
--- a/src/web/routes/signage/templates/add.js
+++ b/src/web/routes/signage/templates/add.js
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
+ region_usage: [],
regions: []
};
}
diff --git a/src/web/routes/signage/templates/list.js b/src/web/routes/signage/templates/list.js
index e311cd8..3fc5ecd 100644
--- a/src/web/routes/signage/templates/list.js
+++ b/src/web/routes/signage/templates/list.js
@@ -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
diff --git a/src/web/views/shared/frame-layout.hbs b/src/web/views/shared/frame-layout.hbs
index 25280e4..2ef1fd2 100644
--- a/src/web/views/shared/frame-layout.hbs
+++ b/src/web/views/shared/frame-layout.hbs
@@ -1,5 +1,5 @@
-
+
@@ -82,6 +82,7 @@
{{/if}}
+
diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs
index f826d0e..1b7a923 100644
--- a/src/web/views/shared/layout.hbs
+++ b/src/web/views/shared/layout.hbs
@@ -1,5 +1,5 @@
-
+
@@ -17,7 +17,7 @@
{{/each}}
{{/if}}
-
+
{{#if errorShell}}
{{{body}}}
@@ -284,6 +284,7 @@
+
diff --git a/src/web/views/signage/templates/add.hbs b/src/web/views/signage/templates/add.hbs
index e77cce4..952faab 100644
--- a/src/web/views/signage/templates/add.hbs
+++ b/src/web/views/signage/templates/add.hbs
@@ -5,13 +5,14 @@
-