init commit

This commit is contained in:
Matt Batchelder
2026-02-11 20:55:38 -05:00
commit 6e3929c459
2240 changed files with 467828 additions and 0 deletions

463
ui/src/core/file-upload.js Normal file
View File

@@ -0,0 +1,463 @@
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
let uploadTemplate = null;
let videoImageCovers = {};
/**
* Opens the upload form
* @param options
*/
window.openUploadForm = function(options) {
options = $.extend(true, {}, {
templateId: 'template-file-upload',
uploadTemplateId: 'template-upload',
downloadTemplateId: 'template-download',
videoImageCovers: true,
className: '',
animateDialog: true,
formOpenedEvent: null,
onHideCallback: null,
templateOptions: {
layoutImport: false,
multi: true,
includeTagsInput: true,
},
}, options);
// Keep a cache of the upload template (unless we are a non-standard form)
if (
uploadTemplate === null ||
options.templateId !== 'template-file-upload'
) {
uploadTemplate = Handlebars.compile($('#' + options.templateId).html());
}
if (typeof maxImagePixelSize === undefined || maxImagePixelSize === '') {
maxImagePixelSize = 0;
}
// Handle bars and open a dialog
const dialog = bootbox.dialog({
message: uploadTemplate(options.templateOptions),
title: options.title,
buttons: options.buttons,
className: options.className + ' upload-modal',
animate: options.animateDialog,
size: 'large',
}).on('hidden.bs.modal', function() {
// Reset video image covers.
videoImageCovers = {};
// Call the onHideCallback if it exists
if (options.onHideCallback !== null) {
options.onHideCallback(dialog);
}
}).attr('id', Date.now());
setTimeout(function() {
console.debug('Timeout fired, we should be shown by now');
// Configure the upload form
const form = $(dialog).find('form');
let uploadOptions = {
url: options.url,
disableImageResize: true,
previewMaxWidth: 100,
previewMaxHeight: 100,
previewCrop: true,
acceptFileTypes: new RegExp(
'\\.(' + options.templateOptions.upload.validExt + ')$',
'i',
),
maxFileSize: options.templateOptions.upload.maxSize,
includeTagsInput: options.templateOptions.includeTagsInput,
uploadTemplateId: options.uploadTemplateId,
limitConcurrentUploads: 3,
};
let refreshSessionInterval;
$(form).on('keydown', function(event) {
if (event.keyCode == 13) {
event.preventDefault();
return false;
}
});
// Video thumbnail capture.
if (options.videoImageCovers) {
$(dialog).find('#files').on('change', handleVideoCoverImage);
}
if (maxImagePixelSize > 0) {
$(dialog).find('#files').on('change', checkImagePixelSize);
}
// If we are not a multi-upload, then limit to 1
if (!options.templateOptions.multi) {
uploadOptions = $.extend({}, uploadOptions, {
maxNumberOfFiles: 1,
limitMultiFileUploads: 1,
});
}
// Widget dates?
if (options.templateOptions.showWidgetDates) {
XiboInitialise('.row-widget-dates');
}
// Handle expiry dates fields
const expiryDatesStatus = function() {
const setExpiryFlag = form.find('#setExpiryDates').is(':checked');
// Hide and disable fiels ( to avoid form submitting)
form.find('.row-widget-set-expiry').toggleClass('hidden', !setExpiryFlag);
form.find('.row-widget-set-expiry input')
.prop('disabled', !setExpiryFlag);
};
// Call when checkbox changes
form.find('#setExpiryDates').on('change', expiryDatesStatus);
// Call on start
expiryDatesStatus();
// Ready to initialise the widget and bind to some events
form
.fileupload(uploadOptions)
.bind('fileuploadsubmit', function(e, data) {
const inputs = data.context.find(':input');
if (inputs.filter('[required][value=""]').first().focus().length) {
return false;
}
data.formData = inputs.serializeArray().concat(form.serializeArray());
inputs.filter('input').prop('disabled', true);
})
.bind('fileuploadstart', function(e, data) {
// Show progress data
form.find('.fileupload-progress .progress-extended').show();
form.find('.fileupload-progress .progress-end').hide();
if (form.fileupload('active') <= 0) {
refreshSessionInterval =
setInterval(
'XiboPing(\'' + pingUrl + '?refreshSession=true\')',
1000 * 60 * 3,
);
}
return true;
})
.bind('fileuploaddone', function(e, data) {
// if we throw an error in the backend, the
// data.result.files is undefined, check if we have a message
if (
data.result.files === undefined &&
data.result.message !== undefined &&
data.result.message != null
) {
toastr.error(data.result.message);
return;
}
// If the upload was an error, then don't process the remaining methods.
if (
data.result.files[0].error != null &&
data.result.files[0].error !== ''
) {
toastr.error(data.result.files[0].error);
return;
}
if (options.videoImageCovers) {
saveVideoCoverImage(data);
}
if (refreshSessionInterval != null && form.fileupload('active') <= 0) {
clearInterval(refreshSessionInterval);
}
// Run the callback function for done when
// we're processing the last uploading element
const filesToUploadCount = form.find('tr.template-upload').length;
if (
filesToUploadCount == 1 &&
options.uploadDoneEvent !== undefined &&
options.uploadDoneEvent !== null &&
typeof options.uploadDoneEvent == 'function'
) {
// Run in a short while.
// this gives time for file-upload's own deferreds to run
setTimeout(function() {
options.uploadDoneEvent(data);
}, 300);
}
})
.bind('fileuploadprogressall', function(e, data) {
// Hide progress data and show processing
if (data.total > 0 && data.loaded === data.total) {
form.find('.fileupload-progress .progress-extended').hide();
form.find('.fileupload-progress .progress-end').show();
}
})
.bind('fileuploadadd', function(e, data) {
if (uploadOptions.limitMultiFileUploads === 1) {
const totalFiles =
form.find('.files > tr.template-upload, tr.template-download')
.length;
// Check if the number of files exceeds the limit
if (totalFiles >= uploadOptions.maxNumberOfFiles) {
// Show toast error message
toastr.error(
translations.canOnlyUploadMax
.replace('%s', uploadOptions.maxNumberOfFiles),
);
// Prevent adding the file
return false;
}
}
})
.bind('fileuploadadded fileuploadcompleted fileuploadfinished',
function(e, data) {
// Get uploaded and downloaded files and toggle Done button
const filesToUploadCount = form.find('tr.template-upload').length;
const $button =
form.parents('.modal:first').find('button.btn-bb-main');
if (!options.templateOptions.includeTagsInput) {
$('.tags-input-container').addClass('d-none');
}
if (filesToUploadCount === 0) {
$button.removeAttr('disabled');
videoImageCovers = {};
} else {
$button.attr('disabled', 'disabled');
}
})
.bind('fileuploaddrop', function(e, data) {
if (options.videoImageCovers) {
handleVideoCoverImage(e, data);
}
if (maxImagePixelSize > 0) {
checkImagePixelSize(e, data);
}
});
if (options.templateOptions.folderSelector) {
// Handle creating a folder selector
// compile tree folder modal and append it to Form
// make bootstrap happy.
if ($('#folder-tree-form-modal').length != 0) {
$('#folder-tree-form-modal').remove();
}
if ($('#folder-tree-form-modal').length === 0) {
const folderTreeModal = templates['folder-tree'];
$('body').append(folderTreeModal({
container: 'container-folder-form-tree',
modal: 'folder-tree-form-modal',
trans: translations.folderTree,
}));
$('#folder-tree-form-modal').on('hidden.bs.modal', function(ev) {
// Fix for 2nd/overlay modal
$('.modal:visible').length && $(document.body).addClass('modal-open');
$(ev.currentTarget).data('bs.modal', null);
});
}
// Init JS Tree
initJsTreeAjax(
$('#folder-tree-form-modal').find('#container-folder-form-tree'),
options.initialisedBy,
true,
600,
);
}
// Handle any form opened event
if (
options.formOpenedEvent !== null &&
options.formOpenedEvent !== undefined
) {
eval(options.formOpenedEvent)(dialog);
}
}, 500);
return dialog;
};
/**
* Binds to a File Input and listens for changes
* when it finds some it sets up for capturing a video thumbnail
* @param e
* @param data
*/
function handleVideoCoverImage(e, data) {
// handle click and drag&drop ways
const files = data === undefined ? e.currentTarget.files : data.files;
let video = null;
// wait a little bit for the preview to be in the form
const checkExist = setInterval(function() {
if ($('.preview').find('video').length) {
let allVideoPreviewsExist = true;
// iterate through our files, check if we have videos
// if we do, then set params on video object,
// convert 2nd second of the video to an image
// and register onseeked and onpause events
Array.from(files).forEach(file => {
if (file.error || !file.type.includes('video')) {
return;
}
if (!file.preview) {
allVideoPreviewsExist = false;
return;
}
video = file.preview;
video.name = file.name;
video.setAttribute('id', file.name);
video.preload = 'metadata';
video.onseeked = createImage;
video.onpause = createImage;
// set current time to trigger event
// and create the cover image
video.currentTime = 2;
});
// Clear interval when all previews exist
if (allVideoPreviewsExist) {
// show help text describing this feature.
const helpText = translations.videoImageCoverHelpText;
const $helpTextSelector = $('.template-upload video:first')
.closest('tr')
.find('td span.info');
$helpTextSelector.empty();
$helpTextSelector.append(helpText);
clearInterval(checkExist);
}
}
}, 200);
}
function createImage() {
// eslint-disable-next-line no-invalid-this
const self = this;
// this will actually create the image
// and save it to an object with file name as a key
const canvas = document.createElement('canvas');
canvas.height = self.videoHeight;
canvas.width = self.videoWidth;
const ctx = canvas.getContext('2d');
ctx.drawImage(self, 0, 0, canvas.width, canvas.height);
const videoImageCover = new Image();
videoImageCover.src = canvas.toDataURL();
videoImageCovers[self.name] = videoImageCover.src;
}
function saveVideoCoverImage(data) {
// this is called when fileUpload is finished
// reason being that we need mediaId to save videoCover image correctly.
const results = data.result.files[0];
const thumbnailData = {};
// we only want to call this for videos
// (it would not do anything for other types).
if (results.mediaType === 'video') {
// get mediaId from results (finished upload)
thumbnailData['mediaId'] = results.mediaId;
// get the base64 image we captured and stored for this file name
thumbnailData['image'] = videoImageCovers[results.fileName];
// remove this key from our object
delete videoImageCovers[results.name];
// this calls function in library controller that decodes the image and
// saves it to library as
// "{libraryLocation}/{$mediaId}_{mediaType}cover.png".
$.ajax({
url: addMediaThumbnailUrl,
type: 'POST',
data: thumbnailData,
});
}
}
/**
* Binds to a File Input and listens for changes,
* if Image was added, check the max resize limit
* and show a warning message if added image is too large
* @param e
* @param data
*/
function checkImagePixelSize(e, data) {
const files = data === undefined ? e.currentTarget.files : data.files;
const $existingFiles = $('.template-upload canvas')
.closest('tr')
.find('td span.info');
const checkExist = setInterval(function() {
if ($('.preview').find('canvas').length) {
// iterate through our files
Array.from(files).forEach(function(file, index) {
if (!file.error && file.type.includes('image')) {
// if we have existing files, adjust index
// to ensure we put the warning in the right place
if ($existingFiles.length > 0) {
if (index === 0) {
index = $existingFiles.length;
} else {
index += $existingFiles.length;
}
}
img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = function() {
if (this.width > maxImagePixelSize ||
this.height > maxImagePixelSize
) {
const helpText = translations.imagePixelSizeTooLarge;
$('.template-upload canvas')
.closest('tr')
.find('td span.info')[index]
.append(helpText);
}
URL.revokeObjectURL(objectUrl);
};
img.src = objectUrl;
}
});
clearInterval(checkExist);
}
}, 300);
}

5237
ui/src/core/forms.js Normal file

File diff suppressed because it is too large Load Diff

453
ui/src/core/help-pane.js Normal file
View File

@@ -0,0 +1,453 @@
/*
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
$(function() {
const $help = $('#help-pane');
const $helpButton = $help.find('.help-pane-btn');
const $helpContainer = $help.find('.help-pane-container');
let fileStore = [];
// 0: Disabled, 1: Main, 2: Feedback form, 3: Feedback outro
let helperStep = 0;
const hideHelper = function() {
$helpContainer.hide();
$('.help-pane-overlay').remove();
};
const renderPanelContent = function() {
if (helperStep === 2) {
// Feedback form
$helpContainer.html(
templates.help.feedbackForm({
trans: translations.helpPane,
pageURL: window.location.pathname,
faultViewUrl: $help.data('faultViewUrl'),
faultViewEnabled: $help.data('faultViewEnabled') == 1,
accountId: accountId,
currentUserName,
currentUserEmail,
}),
);
// Privacy info popover
$helpContainer.find('.help-pane-feedback-privacy-info > i')
.popover({
container: '.help-pane-container',
placement: 'top',
delay: {
show: 200,
hide: 50,
},
trigger: 'hover',
});
handleFileUpload();
} else {
// Main or end panel
const template = (helperStep === 3) ?
templates.help.endPanel :
templates.help.mainPanel;
$helpContainer.html(
template(
{
trans: translations.helpPane,
helpLinks: $('#help-pane').data('helpLinks'),
helpLandingPageURL: $('#help-pane').data('urlHelpLandingPage'),
isXiboThemed,
welcomeViewURL,
supportURL,
appName,
}),
);
}
handleControls();
};
const handleControls = function() {
// Close button
$help.find('.close-icon').on('click', hideHelper);
// Back button
$help.find('.back-icon')
.on('click', (ev) => {
ev.preventDefault();
// Move to previous screen
helperStep--;
renderPanelContent();
});
// Feedback card button
$help.find('.help-pane-card[data-action="feedback_form"]')
.on('click', (ev) => {
ev.preventDefault();
helperStep = 2;
renderPanelContent();
});
// Submit form
$help.find('.submit-form-btn')
.on('click', (ev) => {
ev.preventDefault();
// Show loading
$helpContainer.append(
$(`<div class="help-pane-loader">
<i class="fas fa-spin fa-spinner"></i>
</div>`),
);
const $form = $help.find('form');
const formData = new FormData($form[0]);
const validateEmail = function(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const showErrorOnInput = function($input, msg) {
$input.parent('.xibo-form-input').addClass('invalid');
$input.after($(`<div class="error-message">${msg}</div>`));
};
// Remove invalid class from fields
$form.find('.xibo-form-input.invalid')
.removeClass('invalid');
$form.find('.error-message').remove();
$form.find('.feedback-form-error').addClass('d-none');
// Validate fields
let isValid = true;
// User name
const $userName = $form.find('[name=userName]');
if (!$userName.val().trim()) {
isValid = false;
showErrorOnInput($userName, translations.helpPane.form.errors.name);
}
// Email
const $email = $form.find('[name=email]');
const emailVal = $email.val().trim();
if (!emailVal || !validateEmail(emailVal)) {
isValid = false;
showErrorOnInput($email, translations.helpPane.form.errors.email);
}
// Message
const $message = $form.find('[name=message]');
if (!$message.val().trim()) {
isValid = false;
showErrorOnInput(
$message,
translations.helpPane.form.errors.comments,
);
}
// If any fields are invalid, show form error message
if (!isValid) {
// Hide loading
$helpContainer.find('.help-pane-loader').remove();
// Show error
$form.find('.feedback-form-error span')
.html(translations.helpPane.form.errors.form);
$form.find('.feedback-form-error')
.removeClass('d-none');
return;
}
// Generate 32 char string as id
const rndString =
[...Array(32)].map(
() => (Math.random() * 36 | 0).toString(36),
).join('');
formData.append('id', rndString);
fileStore.forEach((file) => {
formData.append('files[]', file);
});
// Submit form
const requestOptions = {
method: $form.data('method'),
body: formData,
};
fetch($form.data('action'), requestOptions)
.then((res) => {
if (!res.ok) {
throw res;
}
if (res.status === 204) {
// Nothing more to do
return;
}
return response.json();
})
.then((_res) => {
// Clear file store
fileStore = [];
// Hide loading
$helpContainer.find('.help-pane-loader').remove();
// Sucess, go to final screen
helperStep = 3;
renderPanelContent();
})
.catch(async (error) => {
let message = translations.helpPane.form.errors.request;
try {
const data = await error.json();
message = data.message || message;
} catch {
try {
const text = await error.text();
if (text) {
message = text;
}
} catch {}
}
// Hide loading
$helpContainer.find('.help-pane-loader').remove();
$form.find('.feedback-form-error span').html(message);
$form.find('.feedback-form-error').removeClass('d-none');
});
});
};
const handleFileUpload = function() {
// Attachments
const $uploadMain = $help.find('.file-uploader-attachments');
const $uploadsArea = $help.find('.uploads-area');
const $uploadsDrop = $help.find('.uploads-drop');
const $browseLink = $help.find('.upload-text-browse');
const $fileInput = $help.find('#feedback_form_attachments');
const $uploadedFiles = $help.find('.help-pane-upload-files');
const maxFiles = 3;
const maxFileSize = 15 * 1024 * 1024;
const allowedTypes = [
'image/jpeg',
'image/png',
'application/pdf',
'video/quicktime',
];
// Show error message
const showFileErrorMessage = function() {
$uploadMain.append(templates.help.components.errorMessage({
trans: translations.helpPane,
}));
$uploadsArea.hide();
};
const removeFileErrorMessage = function() {
$uploadMain.find('.max-uploads-message').remove();
$uploadsArea.show();
};
// Add uploaded file to form
const addFileCard = function(file) {
$uploadedFiles.append(templates.help.components.uploadCard({
name: file.name,
type: file.fileTypeName,
thumbURL: file.thumbURL,
icon: file.fileIcon,
}));
// Update file container if we reach max files
if ($uploadedFiles.find('.help-pane-upload-file').length >= maxFiles) {
showFileErrorMessage();
}
// Show file container
$uploadedFiles.removeClass('d-none');
};
const handleFilesDrop = function(files) {
if (!files.length) {
return;
}
const currentUploads =
$uploadedFiles.find('.help-pane-upload-file').length;
if (currentUploads + files.length > maxFiles) {
alert(translations.helpPane.form.errors.maxFiles);
return;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!allowedTypes.includes(file.type)) {
alert(
file.name + ': ' +
translations.helpPane.form.errors.invalidFileType);
continue;
}
if (file.size > maxFileSize) {
alert(
file.name + ': ' +
translations.helpPane.form.errors.fileTooLarge);
continue;
}
// Prevent duplicates
if (fileStore.find(
(f) => (f.name === file.name && f.size === file.size))
) {
continue;
}
// Add to file store
fileStore.push(file);
// Render files
if (file.type.startsWith('image/')) {
// Get thumb for image
const reader = new FileReader();
reader.onload = function(e) {
const thumbURL = e.target.result;
addFileCard({
name: file.name,
type: file.type,
thumbURL: thumbURL,
fileTypeName: translations.helpPane.form.image,
});
};
reader.readAsDataURL(file);
} else {
// Get icons for others
if (file.type === 'application/pdf') {
file.fileIcon = 'fa-file-pdf';
file.fileTypeName = translations.helpPane.form.pdf;
} else if (file.type.startsWith('video/')) {
file.fileIcon = 'fa-file-video';
file.fileTypeName = translations.helpPane.form.video;
}
addFileCard(file);
}
}
// Clear file input, we handle on submit
$fileInput.val('');
};
// Browse link
$browseLink.on('click', function(e) {
e.preventDefault();
$fileInput.trigger('click');
});
// Drag and drop
let dragCounter = 0;
$uploadsDrop.on('dragover', function(e) {
e.preventDefault();
}).on('dragenter', function(e) {
e.preventDefault();
dragCounter++;
$uploadsDrop.addClass('highlight');
}).on('dragleave', function(e) {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
$uploadsDrop.removeClass('highlight');
}
}).on('dragend', function() {
dragCounter = 0;
$uploadsDrop.removeClass('highlight');
}).on('drop', function(e) {
e.preventDefault();
dragCounter = 0;
$uploadsDrop.removeClass('highlight');
handleFilesDrop(e.originalEvent.dataTransfer.files);
});
// File input
$fileInput.on('change', function(e) {
handleFilesDrop(e.target.files);
});
$uploadedFiles.on('click', '.remove-file-icon', function(ev) {
const $file = $(ev.currentTarget).closest('.help-pane-upload-file');
const filename = $file.find('.help-pane-upload-file-name').text();
// Remove from file store
fileStore = fileStore.filter((f) => f.name !== filename);
// Remove from container
$file.remove();
const messageLength =
$uploadedFiles.find('.help-pane-upload-file').length;
// Remove error message if num files isn't max
if (messageLength < maxFiles) {
removeFileErrorMessage();
}
// Hide file container if it was the last removed message
if (messageLength === 0) {
$uploadedFiles.addClass('d-none');
}
});
};
// Help main button
$helpButton.on('click', () => {
// If loader is active, skip
if ($helpContainer.find('.help-pane-loader').length > 0) {
return;
}
if ($helpContainer.is(':visible')) {
// Clear file store
fileStore = [];
hideHelper();
} else {
$helpContainer.show();
helperStep = 1;
// Render main panel
renderPanelContent();
$('<div class="help-pane-overlay"></div>')
.appendTo('body')
.on('click', () => {
if (helperStep === 1 || helperStep === 3) {
hideHelper();
}
});
}
});
});

23
ui/src/core/install.js Normal file
View File

@@ -0,0 +1,23 @@
/*
* Xibo - Digital Signage - http://www.xibo.org.uk
* Copyright (C) 2009-2014 Daniel Garner
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
$(function() {
});

3428
ui/src/core/xibo-calendar.js Normal file

File diff suppressed because it is too large Load Diff

3134
ui/src/core/xibo-cms.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff