diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecc07ae..5c3d982 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet.
+## 1.5.4 - 2026-07-25
+
+### Changed
+
+- Cropper.js has been added to aid with image uploading.
+- Upgraded Multer to 2.x to remove the deprecated 1.x dependency warning.
+- Tightened a few admin UI details, including slide editor and RBAC list spacing/typography adjustments.
+
## 1.5.3 - 2026-07-25
### Added
diff --git a/package-lock.json b/package-lock.json
index 48137a3..2b78d9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,21 @@
{
"name": "pulse-signage",
- "version": "1.5.3",
+ "version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "1.5.3",
+ "version": "1.5.4",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
+ "cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
- "multer": "^1.4.5-lts.1",
+ "multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
@@ -1158,17 +1159,17 @@
"license": "MIT"
},
"node_modules/concat-stream": {
- "version": "1.6.2",
- "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
- "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
- "node >= 0.8"
+ "node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
- "readable-stream": "^2.2.2",
+ "readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
@@ -1208,10 +1209,10 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
- "node_modules/core-util-is": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
- "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "node_modules/cropperjs": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
+ "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -2074,12 +2075,6 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
- "node_modules/isarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
- "license": "MIT"
- },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2213,18 +2208,6 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
- "node_modules/mkdirp": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
- "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
- "license": "MIT",
- "dependencies": {
- "minimist": "^1.2.6"
- },
- "bin": {
- "mkdirp": "bin/cmd.js"
- }
- },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -2232,22 +2215,22 @@
"license": "MIT"
},
"node_modules/multer": {
- "version": "1.4.5-lts.2",
- "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
- "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
- "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
+ "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
- "busboy": "^1.0.0",
- "concat-stream": "^1.5.2",
- "mkdirp": "^0.5.4",
- "object-assign": "^4.1.1",
- "type-is": "^1.6.4",
- "xtend": "^4.0.0"
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
},
"engines": {
- "node": ">= 6.0.0"
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/mysql2": {
@@ -2388,15 +2371,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2573,12 +2547,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/process-nextick-args": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
- "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
- "license": "MIT"
- },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -2774,26 +2742,19 @@
}
},
"node_modules/readable-stream": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
- "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
- "core-util-is": "~1.0.0",
- "inherits": "~2.0.3",
- "isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.1",
- "util-deprecate": "~1.0.1"
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
}
},
- "node_modules/readable-stream/node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "license": "MIT"
- },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -3165,20 +3126,14 @@
}
},
"node_modules/string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
- "safe-buffer": "~5.1.0"
+ "safe-buffer": "~5.2.0"
}
},
- "node_modules/string_decoder/node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "license": "MIT"
- },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -3443,15 +3398,6 @@
}
}
},
- "node_modules/xtend": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
- "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4"
- }
- },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
diff --git a/package.json b/package.json
index 3f9d96b..9b4ed1d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "1.5.3",
+ "version": "1.5.4",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
@@ -18,11 +18,12 @@
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
+ "cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
- "multer": "^1.4.5-lts.1",
+ "multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
diff --git a/src/web.js b/src/web.js
index abe6838..f5767c7 100644
--- a/src/web.js
+++ b/src/web.js
@@ -163,6 +163,7 @@ async function start() {
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
+ app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
app.use('/media', express.static(MEDIA_DIR));
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 4845fd0..ba694f8 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -1054,7 +1054,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 0.75rem;
+ gap: 0;
min-height: 3.25rem;
}
diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js
index c1986f3..ed624fa 100644
--- a/src/web/public/js/slides/slide-form.js
+++ b/src/web/public/js/slides/slide-form.js
@@ -773,6 +773,32 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
+ function setRegionImageInputFile(input, file) {
+ if (!input || !file) {
+ return;
+ }
+
+ var dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ input.files = dataTransfer.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ function renderImageRegionPreview(card, value) {
+ if (!card) {
+ return;
+ }
+
+ var previewBox = card.querySelector('.slide-image-region-preview-box');
+ if (!previewBox) {
+ return;
+ }
+
+ previewBox.innerHTML = value
+ ? '
'
+ : '
No image
';
+ }
+
function getRegionBox(region, canvasWidth, canvasHeight) {
return {
left: Math.max(0, Math.round(Number(region.x || 0))),
@@ -1118,7 +1144,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'Text' +
'' +
'' +
- '' +
+ '
' +
'
' +
'' +
'
' +
@@ -1148,7 +1174,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'
RSS' +
'
' +
'
' +
- '' +
+ '
' +
'
' +
'' +
'
' +
@@ -1193,7 +1219,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'
API' +
'
' +
'
' +
- '' +
+ '
' +
'
' +
'' +
'
' +
@@ -1226,7 +1252,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'
' + escapeHtml(region.label) + '' +
'
Webpage' +
'
' +
- '
' +
+ '
' +
'' +
@@ -1245,7 +1271,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'' + escapeHtml(region.label) + '' +
'RTMP' +
'
' +
- '
' +
+ '
' +
'' +
@@ -1266,7 +1292,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'' + escapeHtml(region.label) + '' +
'HTML' +
'
' +
- '
' +
+ '
' +
'' +
@@ -1275,22 +1301,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'
';
}
+ function reduceAspectRatio(width, height) {
+ var a = Math.max(1, Math.round(Number(width || 0) || 1));
+ var b = Math.max(1, Math.round(Number(height || 0) || 1));
+ while (b !== 0) {
+ var remainder = a % b;
+ a = b;
+ b = remainder;
+ }
+ var divisor = Math.max(1, a);
+ return Math.max(1, Math.round(Number(width || 0) || 1) / divisor) + ':' + Math.max(1, Math.round(Number(height || 0) || 1) / divisor);
+ }
+
function renderImageRegion(region) {
var current = getCurrentRegionValue(region);
+ var regionWidth = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
+ var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
+ var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
return '' +
'
' +
'' +
- '
' +
- '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ (current
+ ? '
' +
+ '
 + ')
' +
+ '
' +
+ '
'
+ : '
No image
') +
+ '
' +
+ '
' +
+ '
' +
'
' +
- (current
- ? '
'
- : '
No image selected.
') +
'
' +
'
';
}
@@ -1444,10 +1501,96 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (input.files && input.files.length) {
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
}
+ var regionId = input.name.replace(/^region_image_/, '');
+ var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
+ var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
+ renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
templateSelectorLock.markEdited();
requestPreviewRender();
});
});
+ templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
+ var regionId = zone.getAttribute('data-region-upload-zone');
+ var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
+ if (!input) {
+ return;
+ }
+
+ zone.addEventListener('dragover', function (event) {
+ event.preventDefault();
+ zone.classList.add('is-dragover');
+ });
+
+ zone.addEventListener('dragleave', function () {
+ zone.classList.remove('is-dragover');
+ });
+
+ zone.addEventListener('drop', function (event) {
+ event.preventDefault();
+ zone.classList.remove('is-dragover');
+
+ var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
+ var file = files && files.length ? files[0] : null;
+ if (!file || !/^image\//i.test(file.type || '')) {
+ return;
+ }
+
+ setRegionImageInputFile(input, file);
+ });
+ });
+ templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
+ button.addEventListener('click', function () {
+ var regionId = button.getAttribute('data-remove-region-image');
+ var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
+ var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
+ var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
+
+ if (input && input.dataset.previewUrl) {
+ URL.revokeObjectURL(input.dataset.previewUrl);
+ delete input.dataset.previewUrl;
+ }
+ if (input) {
+ input.value = '';
+ }
+ if (hidden) {
+ hidden.value = '';
+ }
+
+ renderImageRegionPreview(card, '');
+ templateSelectorLock.markEdited();
+ requestPreviewRender();
+ });
+ });
+ templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
+ shell.addEventListener('click', function () {
+ var regionId = shell.getAttribute('data-remove-region-image');
+ var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
+ var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
+ var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
+
+ if (input && input.dataset.previewUrl) {
+ URL.revokeObjectURL(input.dataset.previewUrl);
+ delete input.dataset.previewUrl;
+ }
+ if (input) {
+ input.value = '';
+ }
+ if (hidden) {
+ hidden.value = '';
+ }
+
+ renderImageRegionPreview(card, '');
+ templateSelectorLock.markEdited();
+ requestPreviewRender();
+ });
+
+ shell.addEventListener('keydown', function (event) {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ shell.click();
+ }
+ });
+ });
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
input.addEventListener('input', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
diff --git a/src/web/public/js/slides/slide-image-cropper.js b/src/web/public/js/slides/slide-image-cropper.js
new file mode 100644
index 0000000..a1a852a
--- /dev/null
+++ b/src/web/public/js/slides/slide-image-cropper.js
@@ -0,0 +1,415 @@
+(function () {
+ var templateFields = document.getElementById('template-fields');
+ var modal = document.getElementById('slide-image-cropper-modal');
+ var image = document.getElementById('slide-image-cropper-image');
+ var status = document.getElementById('slide-image-cropper-status');
+
+ if (!templateFields || !modal || !image || !status) {
+ return;
+ }
+
+ var cropper = null;
+ var currentInput = null;
+ var currentFile = null;
+ var currentObjectUrl = '';
+ var flipX = 1;
+ var flipY = 1;
+ var currentAspectRatio = NaN;
+ var currentRegionAspectRatio = NaN;
+ var currentRegionAspectRatioLabel = 'Region';
+ var listenersAttached = false;
+
+ function getRegionId(input) {
+ var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
+ return match ? match[1] : '';
+ }
+
+ function getExistingInput(regionId) {
+ if (!regionId) {
+ return null;
+ }
+
+ return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
+ }
+
+ function setStatus(message) {
+ status.textContent = String(message || '');
+ }
+
+ function showModal() {
+ if (window.pulseModal && typeof window.pulseModal.show === 'function') {
+ window.pulseModal.show(modal);
+ return;
+ }
+
+ if (window.bootstrap && window.bootstrap.Modal) {
+ window.bootstrap.Modal.getOrCreateInstance(modal).show();
+ }
+ }
+
+ function hideModal() {
+ if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
+ window.pulseModal.hide(modal);
+ return;
+ }
+
+ if (window.bootstrap && window.bootstrap.Modal) {
+ window.bootstrap.Modal.getOrCreateInstance(modal).hide();
+ }
+ }
+
+ function destroyCropper() {
+ if (cropper) {
+ cropper.destroy();
+ cropper = null;
+ }
+ }
+
+ function revokeObjectUrl() {
+ if (currentObjectUrl) {
+ URL.revokeObjectURL(currentObjectUrl);
+ currentObjectUrl = '';
+ }
+ }
+
+ function resetModalState() {
+ destroyCropper();
+ revokeObjectUrl();
+ currentInput = null;
+ currentFile = null;
+ flipX = 1;
+ flipY = 1;
+ image.removeAttribute('src');
+ image.alt = 'Selected image to crop';
+ setStatus('');
+ }
+
+ function setButtonState(disabled) {
+ modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
+ button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
+ });
+ }
+
+ function ratioLabelForValue(value) {
+ if (value === 'free') {
+ return 'free';
+ }
+
+ if (value === 'region') {
+ return 'region';
+ }
+
+ return String(value || '').trim();
+ }
+
+ function parseAspectRatio(value) {
+ var parts = String(value || '').trim().split(/[:/]/);
+ var width = Number(parts[0]);
+ var height = Number(parts[1]);
+
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return NaN;
+ }
+
+ return width / height;
+ }
+
+ function setActiveRatioButton(value) {
+ var targetValue = ratioLabelForValue(value);
+ modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
+ var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
+ var isActive = buttonValue === targetValue;
+ button.classList.toggle('active', isActive);
+ button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
+ });
+ }
+
+ function applyAspectRatio(value) {
+ if (!cropper) {
+ return;
+ }
+
+ currentAspectRatio = value;
+ if (value === 'free') {
+ cropper.setAspectRatio(NaN);
+ setActiveRatioButton('free');
+ return;
+ }
+
+ if (value === 'region') {
+ if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
+ cropper.setAspectRatio(NaN);
+ setActiveRatioButton('free');
+ return;
+ }
+
+ cropper.setAspectRatio(currentRegionAspectRatio);
+ setActiveRatioButton('region');
+ return;
+ }
+
+ var parts = String(value || '').split(':');
+ var width = Number(parts[0]);
+ var height = Number(parts[1]);
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return;
+ }
+
+ cropper.setAspectRatio(width / height);
+ setActiveRatioButton(value);
+ }
+
+ function initCropper() {
+ if (!currentInput || !currentFile || !window.Cropper) {
+ return;
+ }
+
+ destroyCropper();
+ cropper = new window.Cropper(image, {
+ aspectRatio: NaN,
+ autoCropArea: 1,
+ background: false,
+ dragMode: 'move',
+ initialAspectRatio: NaN,
+ movable: true,
+ responsive: true,
+ rotatable: true,
+ scalable: true,
+ viewMode: 1,
+ zoomOnTouch: true,
+ zoomOnWheel: true,
+ ready: function () {
+ if (cropper && cropper.container) {
+ cropper.container.style.width = '100%';
+ cropper.container.style.height = '560px';
+ cropper.container.style.maxHeight = '70vh';
+ }
+
+ var containerData = cropper.getContainerData();
+ var imageData = cropper.getImageData();
+ var fitRatio = Math.min(
+ Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
+ Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
+ 1
+ );
+
+ if (Number.isFinite(fitRatio) && fitRatio > 0) {
+ cropper.zoomTo(fitRatio);
+ } else {
+ cropper.reset();
+ }
+
+ applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
+ }
+ });
+
+ setButtonState(false);
+ setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
+ setStatus('Use the toolbar to crop, rotate, or flip the image before applying it.');
+ }
+
+ function openEditor(input, file) {
+ currentInput = input;
+ currentFile = file;
+ flipX = 1;
+ flipY = 1;
+ currentAspectRatio = 'free';
+ currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
+ currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
+ setButtonState(true);
+ setStatus('Loading image editor...');
+
+ modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
+ button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
+ });
+
+ revokeObjectUrl();
+ currentObjectUrl = URL.createObjectURL(file);
+ image.alt = file.name || 'Selected image to crop';
+ image.src = currentObjectUrl;
+
+ if (!listenersAttached) {
+ listenersAttached = true;
+ modal.addEventListener('shown.bs.modal', function () {
+ if (currentInput && currentFile) {
+ initCropper();
+ }
+ });
+ modal.addEventListener('hidden.bs.modal', function () {
+ resetModalState();
+ });
+ }
+
+ showModal();
+ }
+
+ function setInputFile(input, file) {
+ var dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ input.files = dataTransfer.files;
+ }
+
+ function finalizeCropFile(file) {
+ if (!currentInput) {
+ return;
+ }
+
+ var input = currentInput;
+ var existing = getExistingInput(getRegionId(input));
+
+ input.dataset.cropperBypass = '1';
+ setInputFile(input, file);
+ if (existing) {
+ existing.value = '';
+ }
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ window.setTimeout(function () {
+ delete input.dataset.cropperBypass;
+ }, 0);
+ hideModal();
+ }
+
+ function commitCrop() {
+ if (!currentInput || !currentFile) {
+ return;
+ }
+
+ if (!cropper) {
+ finalizeCropFile(currentFile);
+ return;
+ }
+
+ setButtonState(true);
+ setStatus('Creating the cropped image...');
+
+ var canvas = cropper.getCroppedCanvas({
+ fillColor: 'transparent',
+ imageSmoothingEnabled: true,
+ imageSmoothingQuality: 'high'
+ });
+
+ if (!canvas) {
+ setStatus('Unable to create the cropped image.');
+ setButtonState(false);
+ return;
+ }
+
+ var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
+ canvas.toBlob(function (blob) {
+ if (!blob) {
+ setStatus('Unable to create the cropped image.');
+ setButtonState(false);
+ return;
+ }
+
+ var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
+ lastModified: Date.now(),
+ type: blob.type || outputType
+ });
+
+ finalizeCropFile(finalFile);
+ }, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
+ }
+
+ function rotateImage(direction) {
+ if (cropper) {
+ cropper.rotate(direction);
+ }
+ }
+
+ function flipImage(axis) {
+ if (!cropper) {
+ return;
+ }
+
+ if (axis === 'x') {
+ flipX *= -1;
+ cropper.scaleX(flipX);
+ return;
+ }
+
+ flipY *= -1;
+ cropper.scaleY(flipY);
+ }
+
+ function resetImage() {
+ if (!cropper) {
+ return;
+ }
+
+ cropper.reset();
+ flipX = 1;
+ flipY = 1;
+ }
+
+ templateFields.addEventListener('change', function (event) {
+ var input = event.target;
+ if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
+ return;
+ }
+
+ if (input.dataset.cropperBypass === '1') {
+ return;
+ }
+
+ var file = input.files && input.files[0];
+ if (!file) {
+ return;
+ }
+
+ if (!/^image\//i.test(file.type || '')) {
+ input.value = '';
+ return;
+ }
+
+ event.stopImmediatePropagation();
+ event.stopPropagation();
+ input.value = '';
+
+ if (!window.Cropper) {
+ currentInput = input;
+ currentFile = file;
+ commitCrop();
+ return;
+ }
+
+ openEditor(input, file);
+ }, true);
+
+ modal.addEventListener('click', function (event) {
+ var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
+ if (!button) {
+ return;
+ }
+
+ var action = button.getAttribute('data-slide-image-cropper-action');
+ if (action === 'apply') {
+ commitCrop();
+ return;
+ }
+ if (action === 'ratio') {
+ applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
+ return;
+ }
+ if (action === 'rotate-left') {
+ rotateImage(-90);
+ return;
+ }
+ if (action === 'rotate-right') {
+ rotateImage(90);
+ return;
+ }
+ if (action === 'flip-horizontal') {
+ flipImage('x');
+ return;
+ }
+ if (action === 'flip-vertical') {
+ flipImage('y');
+ return;
+ }
+ if (action === 'reset') {
+ resetImage();
+ return;
+ }
+ });
+}());
\ No newline at end of file
diff --git a/src/web/views/settings/rbac/list.hbs b/src/web/views/settings/rbac/list.hbs
index 36492c9..2f7eec1 100644
--- a/src/web/views/settings/rbac/list.hbs
+++ b/src/web/views/settings/rbac/list.hbs
@@ -38,7 +38,7 @@
|
diff --git a/src/web/views/signage/slides/form.hbs b/src/web/views/signage/slides/form.hbs
index 4a3610f..f2d0034 100644
--- a/src/web/views/signage/slides/form.hbs
+++ b/src/web/views/signage/slides/form.hbs
@@ -6,6 +6,7 @@