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 + ? 'Current image preview' + : '
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 '' + '
' + '
' + '' + escapeHtml(region.label) + '' + 'Image' + '
' + - '
' + - '' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + (current + ? '
' + + 'Current image preview' + + '' + + '
' + : '
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 @@
- {{name}} + {{name}}
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 @@
+
@@ -35,7 +36,7 @@
-
+
{{{saveActionButtons formId="slide-form" saveLabel=saveLabel saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}} Cancel @@ -67,10 +68,216 @@
+{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}} + + + +{{/modal-shell}} + + + {{#if isEdit}} {{/if}} - + + +