Release 2.21.3: multi-file media uploads
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m22s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
lzstealth committed 2026-10-26 23:32:31 +00:00
1 parent 24a566a1a4
commit 75b01d49a9
8 files changed
+138 -81

No files matched your search

+86 -45
View File
@@ -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.');
});
});
}
@@ -115,11 +115,11 @@
<form method="post" action="/media-library" enctype="multipart/form-data">
<div class="modal-body">
<label class="slide-image-region-upload-zone media-library-upload-zone" for="media-library-upload" data-media-library-upload-zone>
<input type="file" id="media-library-upload" name="file" class="visually-hidden" accept="{{#each uploadLimits.allowedMimeTypes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}" data-media-library-upload data-image-max-bytes="{{uploadLimits.imageMaxBytes}}" data-video-max-bytes="{{uploadLimits.videoMaxBytes}}" required />
<input type="file" id="media-library-upload" name="file" class="visually-hidden" accept="{{#each uploadLimits.allowedMimeTypes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}" data-media-library-upload data-image-max-bytes="{{uploadLimits.imageMaxBytes}}" data-video-max-bytes="{{uploadLimits.videoMaxBytes}}" multiple required />
<span class="slide-image-region-upload-zone-content">
<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>
<span class="slide-image-region-upload-zone-copy">
<strong>Drop an image or video here or click to upload</strong>
<strong>Drop images or videos here or click to select files</strong>
<span>Images and videos enabled in Media Uploads settings</span>
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>