From 4afaeaafb1fc2eeabe79c79c27a4111c6df02038 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 26 Jul 2026 16:22:28 +0100 Subject: [PATCH] Release v1.5.12 --- CHANGELOG.md | 11 + package-lock.json | 4 +- package.json | 2 +- src/data/admin.js | 3 +- src/web/public/css/theme-custom.css | 57 +- src/web/public/js/admin-page.js | 599 ----------------- src/web/public/js/admin/admin-page.js | 87 +-- src/web/public/js/dashboard/dashboard-page.js | 86 +-- .../public/js/playlists/playlist-schedule.js | 444 +++++++------ src/web/public/js/template-designer.js | 606 ------------------ .../public/js/templates/template-designer.js | 71 +- src/web/public/js/web-ui-helpers.js | 94 +++ src/web/routes/admin/content.js | 55 ++ src/web/routes/signage/templates/add.js | 1 + src/web/routes/signage/templates/list.js | 3 + src/web/views/shared/frame-layout.hbs | 3 +- src/web/views/shared/layout.hbs | 5 +- src/web/views/signage/templates/add.hbs | 15 +- src/web/views/signage/templates/edit.hbs | 15 +- src/web/views/signage/templates/list.hbs | 4 +- 20 files changed, 563 insertions(+), 1602 deletions(-) delete mode 100644 src/web/public/js/admin-page.js delete mode 100644 src/web/public/js/template-designer.js create mode 100644 src/web/public/js/web-ui-helpers.js 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 '' + + '' + + '
' + + '' + + '
' + 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 = '' + - '' + - '
' + - '' + - '' + - '
' + - '' + - '' + - '
' + - '' + - '
' + 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 @@ -
+ +
@@ -30,7 +31,7 @@
-
+
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}} Cancel @@ -114,20 +115,20 @@ {{/modal-shell}} diff --git a/src/web/views/signage/templates/edit.hbs b/src/web/views/signage/templates/edit.hbs index 6b826f4..a8939ab 100644 --- a/src/web/views/signage/templates/edit.hbs +++ b/src/web/views/signage/templates/edit.hbs @@ -5,13 +5,14 @@
- + +
@@ -30,7 +31,7 @@
-
+
{{{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"}}} Cancel @@ -121,20 +122,20 @@ {{/modal-shell}} diff --git a/src/web/views/signage/templates/list.hbs b/src/web/views/signage/templates/list.hbs index 975191c..8a292d3 100644 --- a/src/web/views/signage/templates/list.hbs +++ b/src/web/views/signage/templates/list.hbs @@ -25,6 +25,7 @@ Name Canvas Regions + Slides used by Actions @@ -35,6 +36,7 @@ {{name}} {{canvas_size_width}}x{{canvas_size_height}} {{regionCount}} + {{slideCount}} {{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}}
@@ -54,7 +56,7 @@ {{/each}} {{else}} - No templates yet. + No templates yet. {{/if}}