From 75b01d49a973781d2675a15a69351264711519e9 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Mon, 26 Oct 2026 23:32:31 +0000 Subject: [PATCH] Release 2.21.3: multi-file media uploads --- CHANGELOG.md | 6 + build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/web/public/js/settings/media-library.js | 131 ++++++++++++------ src/web/views/settings/media-library/list.hbs | 4 +- test/media-library.test.js | 68 +++++---- 8 files changed, 138 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1adb8..dd6f36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.21.3 - 2026-10-26 + +### Added + +- Media library uploads accept multiple files at once, show per-file progress, and continue the batch when an individual file fails. + ## 2.21.2 - 2026-10-26 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index 0b033e3..5035b81 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.21.2", + "version": "2.21.3", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 437227e..de46eff 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.21.2", + "version": "2.21.3", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 7a8badc..00b59ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.21.2", + "version": "2.21.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.21.2", + "version": "2.21.3", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index aae49fa..a29fe48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.21.2", + "version": "2.21.3", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/web/public/js/settings/media-library.js b/src/web/public/js/settings/media-library.js index e173b69..5954225 100644 --- a/src/web/public/js/settings/media-library.js +++ b/src/web/public/js/settings/media-library.js @@ -146,32 +146,43 @@ if (addMediaButton && uploadModal) { column.querySelector('.media-gallery-card').classList.toggle('media-gallery-card-selected', selectedAssetIds.has(String(asset.id || ''))); } -function setUploadFile(file) { - if (!uploadInput || uploadInput.disabled || !file) { - return false; - } +function getUploadFileError(file) { const allowedTypes = String(uploadInput.accept || '').split(',').map(function (value) { return value.trim().toLowerCase(); }).filter(Boolean); const fileType = String(file.type || '').toLowerCase(); const matchesType = allowedTypes.length === 0 || allowedTypes.indexOf(fileType) !== -1; const maxBytes = fileType.indexOf('video/') === 0 ? Number(uploadInput.dataset.videoMaxBytes) : Number(uploadInput.dataset.imageMaxBytes); if (!matchesType) { - window.PulseUpload.showMessage('This media type is disabled in Media Uploads settings.'); - uploadInput.value = ''; - return false; + return 'This media type is disabled in Media Uploads settings.'; } if (maxBytes && file.size > maxBytes) { - window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.'); + return file.name + ' is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.'; + } + return ''; +} + +function setUploadFiles(files) { + if (!uploadInput || uploadInput.disabled || !files || !files.length) return false; + const validFiles = []; + const errors = []; + Array.from(files).forEach(function (file) { + const error = getUploadFileError(file); + if (error) errors.push(error); + else validFiles.push(file); + }); + if (errors.length) { + const uniqueErrors = Array.from(new Set(errors)); + window.PulseUpload.showMessage((errors.length === 1 ? '' : errors.length + ' files skipped. ') + uniqueErrors.slice(0, 3).join(' ')); + } + if (!validFiles.length) { uploadInput.value = ''; return false; } - const transfer = new DataTransfer(); - transfer.items.add(file); + validFiles.forEach(function (file) { transfer.items.add(file); }); uploadInput.files = transfer.files; if (uploadName) { - uploadName.textContent = file.name; + uploadName.textContent = validFiles.length === 1 ? validFiles[0].name : validFiles.length + ' files selected'; } - return true; } @@ -188,8 +199,8 @@ function submitUpload() { if (uploadInput) { uploadInput.addEventListener('change', function () { - const file = uploadInput.files && uploadInput.files[0]; - if (file && setUploadFile(file)) { + const files = uploadInput.files; + if (files && setUploadFiles(files)) { submitUpload(); } }); @@ -206,8 +217,8 @@ if (uploadZone) { uploadZone.addEventListener('drop', function (event) { event.preventDefault(); uploadZone.classList.remove('is-dragover'); - const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]; - if (file && setUploadFile(file)) { + const files = event.dataTransfer && event.dataTransfer.files; + if (files && setUploadFiles(files)) { submitUpload(); } }); @@ -238,17 +249,16 @@ if (uploadForm) { uploadForm.addEventListener('submit', function (event) { event.preventDefault(); if (uploadInProgress) return; - if (uploadInput && !uploadInput.files.length) { + const files = uploadInput ? Array.from(uploadInput.files || []) : []; + if (!files.length) { window.PulseUpload.showMessage('Choose an image or video to upload.'); return; } - const formData = new FormData(uploadForm); - const xhr = new XMLHttpRequest(); const buttons = uploadModal ? Array.from(uploadModal.querySelectorAll('button')).map(button => ({ button, disabled: button.disabled })) : []; uploadInProgress = true; if (uploadInput) uploadInput.disabled = true; buttons.forEach(state => { state.button.disabled = true; }); - setUploadProgress(0, 'Uploading...', true); + setUploadProgress(0, 'Uploading 1 of ' + files.length + ': ' + files[0].name, true); function finish() { uploadInProgress = false; @@ -256,35 +266,66 @@ if (uploadForm) { buttons.forEach(state => { state.button.disabled = state.disabled; }); setUploadProgress(0, '', false); } - function fail(message) { - finish(); - window.PulseUpload.showMessage(message); + + function uploadFile(file, index) { + return new Promise(function (resolve) { + const xhr = new XMLHttpRequest(); + const fileLabel = (index + 1) + ' of ' + files.length + ': ' + file.name; + const formData = new FormData(); + formData.append('file', file, file.name); + xhr.open('POST', uploadForm.action, true); + xhr.withCredentials = true; + xhr.timeout = 300000; + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.upload.onprogress = function (progressEvent) { + if (!progressEvent.lengthComputable || !progressEvent.total) { setUploadProgress(100, 'Uploading ' + fileLabel, true); return; } + const percent = Math.min(100, Math.round(progressEvent.loaded / progressEvent.total * 100)); + setUploadProgress(percent, (percent === 100 ? 'Processing ' : 'Uploading ') + fileLabel + (percent === 100 ? '' : ' (' + percent + '%)'), percent === 100); + }; + xhr.upload.onload = function () { setUploadProgress(100, 'Processing ' + fileLabel, true); }; + xhr.onload = function () { + let result; + try { result = JSON.parse(xhr.responseText || '{}'); } catch (_error) { + resolve({ success: false, error: 'Unable to upload. Check your sign-in status and retry.' }); + return; + } + resolve(xhr.status >= 200 && xhr.status < 300 && result.success + ? { success: true } + : { success: false, error: result.error || 'Unable to upload.' }); + }; + xhr.onerror = function () { resolve({ success: false, error: 'Unable to upload. Check your connection and retry.' }); }; + xhr.onabort = function () { resolve({ success: false, error: 'Upload cancelled.' }); }; + xhr.ontimeout = function () { resolve({ success: false, error: 'Upload timed out.' }); }; + try { xhr.send(formData); } catch (_error) { resolve({ success: false, error: 'Unable to upload. Please try again.' }); } + }); } - xhr.open('POST', uploadForm.action, true); - xhr.withCredentials = true; - xhr.timeout = 300000; - xhr.setRequestHeader('Accept', 'application/json'); - xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); - xhr.upload.onprogress = function (event) { - if (!event.lengthComputable || !event.total) { setUploadProgress(100, 'Uploading...', true); return; } - const percent = Math.min(100, Math.round(event.loaded / event.total * 100)); - setUploadProgress(percent, percent === 100 ? 'Processing...' : 'Uploading ' + percent + '%', percent === 100); - }; - xhr.upload.onload = function () { setUploadProgress(100, 'Processing...', true); }; - xhr.onload = async function () { - let result; - try { result = JSON.parse(xhr.responseText || '{}'); } catch (_error) { fail('Unable to upload media. Check your sign-in status and retry.'); return; } - if (xhr.status < 200 || xhr.status >= 300 || !result.success) { fail(result.error || 'Unable to upload media.'); return; } + + (async function () { + const succeeded = []; + const failed = []; + for (let index = 0; index < files.length; index += 1) { + const result = await uploadFile(files[index], index); + if (result.success) succeeded.push(files[index]); + else failed.push({ file: files[index], error: result.error }); + } finish(); if (uploadInput) uploadInput.value = ''; hideModal(uploadModal); - if (typeof window.showToast === 'function') window.showToast('Media uploaded.', 'success'); - await refreshGallery(); - }; - xhr.onerror = function () { fail('Unable to upload media. Check your connection and retry.'); }; - xhr.onabort = function () { fail('Upload cancelled.'); }; - xhr.ontimeout = function () { fail('Upload timed out. Please try again.'); }; - try { xhr.send(formData); } catch (_error) { fail('Unable to upload media. Please try again.'); } + if (succeeded.length && typeof window.showToast === 'function') { + const message = succeeded.length === 1 ? 'Media uploaded.' : succeeded.length + ' media files uploaded.'; + window.showToast(message, 'success'); + } + if (failed.length) { + const details = failed.slice(0, 3).map(function (item) { return item.file.name + ': ' + item.error; }); + if (failed.length > details.length) details.push((failed.length - details.length) + ' more failed.'); + window.PulseUpload.showMessage((succeeded.length ? succeeded.length + ' uploaded. ' : 'No media files were uploaded. ') + details.join(' ')); + } + if (succeeded.length) await refreshGallery(); + })().catch(function () { + finish(); + window.PulseUpload.showMessage('Unable to finish uploading media. Please refresh the library and check the results.'); + }); }); } diff --git a/src/web/views/settings/media-library/list.hbs b/src/web/views/settings/media-library/list.hbs index 6b80e1f..3b58c9f 100644 --- a/src/web/views/settings/media-library/list.hbs +++ b/src/web/views/settings/media-library/list.hbs @@ -115,11 +115,11 @@