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

40
ui/bundle_code_editor.js Normal file
View File

@@ -0,0 +1,40 @@
/*
* 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/>.
*/
// CodeMirror
import {basicSetup} from 'codemirror';
import {EditorView, keymap} from '@codemirror/view';
import {indentWithTab} from '@codemirror/commands';
import {javascript} from '@codemirror/lang-javascript';
import {css} from '@codemirror/lang-css';
import {html} from '@codemirror/lang-html';
import {twig} from '@ssddanbrown/codemirror-lang-twig';
window.CodeMirror = {
basicSetup,
EditorView,
keymap,
indentWithTab,
javascript,
css,
html,
twig,
};

25
ui/bundle_datatables.js Normal file
View File

@@ -0,0 +1,25 @@
// --- NPM packages style ---
// import './public_path';
// JS
const DT_EXTRAS = [
require('datatables.net'),
require('datatables.net-bs4'),
require('datatables.net-buttons'),
require('datatables.net-buttons/js/buttons.colVis.min.js'),
require('datatables.net-buttons/js/buttons.html5.min.js'),
require('datatables.net-buttons/js/buttons.print.min.js'),
require('datatables.net-buttons-bs4'),
require('datatables.net-responsive'),
];
DT_EXTRAS.forEach(function(e) {
if (typeof e === 'function') {
e(window, window.$);
}
});
// Style
require('datatables.net-bs4/css/dataTables.bootstrap4.min.css');
require('datatables.net-buttons-bs4/css/buttons.bootstrap4.min.css');
require('datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css');

View File

@@ -0,0 +1,40 @@
/*
* 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/>.
*/
// --- Add NPM Packages - JS ----
import './public_path';
// Masonry
window.Masonry = require('masonry-layout');
// images loaded
const imagesLoaded = require('imagesloaded');
// provide jQuery argument
imagesLoaded.makeJQueryPlugin( window.$ );
// moveable
window.Moveable = require('moveable/dist/moveable.min.js');
window.Selecto = require('selecto/dist/selecto.min.js');
// Leader Line
import {LeaderLine}
from 'exports-loader?exports=LeaderLine!leader-line/leader-line.min.js';
window.LeaderLine = LeaderLine;

50
ui/bundle_leaflet.js Normal file
View File

@@ -0,0 +1,50 @@
/*
* 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/>.
*/
// --- Add NPM Packages - JS ----
import './public_path';
// leaflet
require('leaflet');
require('leaflet-draw');
require('leaflet-search');
window.L = require('leaflet');
window.leafletPip = require('@mapbox/leaflet-pip');
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/dist/assets/marker-icon-2x.png',
iconUrl: '/dist//assets/marker-icon.png',
shadowUrl: '/dist/assets/marker-shadow.png',
});
require('leaflet.markercluster');
require('leaflet-easyprint');
require('leaflet-fullscreen');
// Style
require('leaflet/dist/leaflet.css');
require('leaflet-draw/dist/leaflet.draw-src.css');
require('leaflet-search/dist/leaflet-search.src.css');
require('leaflet.markercluster/dist/MarkerCluster.css');
require('leaflet.markercluster/dist/MarkerCluster.Default.css');
require('leaflet-fullscreen/dist/leaflet.fullscreen.css');

28
ui/bundle_preview.js Normal file
View File

@@ -0,0 +1,28 @@
/*
* 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/>.
*/
// jquery-ui
window.jQuery = window.$ = require('jquery');
// XLR
import '@xibosignage/xibo-layout-renderer/dist/styles.css';
import XiboLayoutRenderer from '@xibosignage/xibo-layout-renderer';
window.XiboLayoutRenderer = XiboLayoutRenderer;

56
ui/bundle_style.js Normal file
View File

@@ -0,0 +1,56 @@
// --- NPM packages style ---
import './public_path';
// font-awesome
require('@fortawesome/fontawesome-free/css/all.min.css');
// bootstrap ( themed )
require('./src/style/bootstrap_theme.scss');
// bootstrap-colorpicker
require('bootstrap-colorpicker/dist/css/bootstrap-colorpicker.min.css');
// bootstrap-select
require('bootstrap-select/dist/css/bootstrap-select.min.css');
// select2
require('select2/dist/css/select2.min.css');
// select2-bootstrap-theme
require('select2-bootstrap-theme/dist/select2-bootstrap.min.css');
// jqueryui
require('jquery-ui/themes/base/core.css');
require('jquery-ui/themes/base/menu.css');
require('jquery-ui/themes/base/autocomplete.css');
require('jquery-ui/themes/base/theme.css');
// toastr
require('toastr/build/toastr.min.css');
// bootstrap-switch
require('bootstrap-switch/dist/css/bootstrap3/bootstrap-switch.css');
// bootstrap-slider
require('bootstrap-slider/dist/css/bootstrap-slider.min.css');
// bootstrap-tagsinput
require('bootstrap-tagsinput/dist/bootstrap-tagsinput.css');
// font-awesome
require('font-awesome/css/font-awesome.min.css');
// Persian date picker
require('persian-datepicker/dist/css/persian-datepicker.min.css');
// Time/Date picker
require('flatpickr/dist/flatpickr.min.css');
require('flatpickr/dist/plugins/monthSelect/style.css');
require('./src/vendor/calendar/css/calendar.css');
require('./src/vendor/jquery-file-upload/css/jquery.fileupload.css');
require('./src/vendor/jquery-file-upload/css/jquery.fileupload-ui.css');
require(
'./src/vendor/jquery-ui/css/ui-lightness/jquery-ui-1.10.2.custom.min.css',
);

175
ui/bundle_templates.js Normal file
View File

@@ -0,0 +1,175 @@
// --- Build all Global templates ----
window.templates = {
forms: {
addOns: {
helpText: require('./src/templates/forms/inputs/add-ons/helpText.hbs'),
playerCompatibility:
require('./src/templates/forms/inputs/add-ons/playerCompatibility.hbs'),
customPopOver:
require('./src/templates/forms/inputs/add-ons/customPopOver.hbs'),
dropdownOptionImage:
require('./src/templates/forms/inputs/add-ons/dropdownOptionImage.hbs'),
dateFormatHelperPopup:
require(
'./src/templates/forms/inputs/add-ons/dateFormatHelperPopup.hbs',
),
},
group: require('./src/templates/forms/group.hbs'),
button: require('./src/templates/forms/button.hbs'),
text: require('./src/templates/forms/inputs/text.hbs'),
checkbox: require('./src/templates/forms/inputs/checkbox.hbs'),
number: require('./src/templates/forms/inputs/number.hbs'),
dropdown: require('./src/templates/forms/inputs/dropdown.hbs'),
color: require('./src/templates/forms/inputs/color.hbs'),
colorGradient: require('./src/templates/forms/inputs/colorGradient.hbs'),
code: require('./src/templates/forms/inputs/code.hbs'),
message: require('./src/templates/forms/inputs/message.hbs'),
hidden: require('./src/templates/forms/inputs/hidden.hbs'),
date: require('./src/templates/forms/inputs/date.hbs'),
header: require('./src/templates/forms/inputs/header.hbs'),
richText: require('./src/templates/forms/inputs/richText.hbs'),
divider: require('./src/templates/forms/inputs/divider.hbs'),
buttonSwitch: require('./src/templates/forms/inputs/buttonSwitch.hbs'),
custom: require('./src/templates/forms/inputs/custom.hbs'),
keyCapture: require('./src/templates/forms/inputs/keyCapture.hbs'),
datasetSelector:
require('./src/templates/forms/inputs/datasetSelector.hbs'),
menuBoardSelector:
require('./src/templates/forms/inputs/menuBoardSelector.hbs'),
menuBoardCategorySelector:
require('./src/templates/forms/inputs/menuBoardCategorySelector.hbs'),
datasetOrder: require('./src/templates/forms/inputs/datasetOrder.hbs'),
datasetFilter: require('./src/templates/forms/inputs/datasetFilter.hbs'),
datasetColumnSelector:
require('./src/templates/forms/inputs/datasetColumnSelector.hbs'),
datasetField: require('./src/templates/forms/inputs/datasetField.hbs'),
fontSelector: require('./src/templates/forms/inputs/fontSelector.hbs'),
effectSelector: require('./src/templates/forms/inputs/effectSelector.hbs'),
worldClock: require('./src/templates/forms/inputs/worldClock.hbs'),
mediaSelector: require('./src/templates/forms/inputs/mediaSelector.hbs'),
languageSelector:
require('./src/templates/forms/inputs/languageSelector.hbs'),
forecastUnitsSelector:
require('./src/templates/forms/inputs/forecastUnitsSelector.hbs'),
commandSelector:
require('./src/templates/forms/inputs/commandSelector.hbs'),
commandBuilder: require('./src/templates/forms/inputs/commandBuilder.hbs'),
connectorProperties:
require('./src/templates/forms/inputs/connectorProperties.hbs'),
playlistMixer: require('./src/templates/forms/inputs/playlistMixer.hbs'),
snippet: require('./src/templates/forms/inputs/snippet.hbs'),
textArea: require('./src/templates/forms/inputs/textArea.hbs'),
canvasWidgetsSelector:
require('./src/templates/forms/inputs/canvasWidgetsSelector.hbs'),
widgetInfo:
require('./src/templates/forms/inputs/widgetInfo.hbs'),
tickerTagSelector:
require('./src/templates/forms/inputs/tickerTagSelector.hbs'),
tickerTagStyle:
require('./src/templates/forms/inputs/tickerTagStyle.hbs'),
datasetColStyleSelector:
require('./src/templates/forms/inputs/datasetColStyleSelector.hbs'),
datasetColStyle:
require('./src/templates/forms/inputs/datasetColStyle.hbs'),
imageReplaceControl:
require('./src/templates/forms/inputs/imageReplace.hbs'),
fallbackDataContent:
require('./src/templates/fallback-data-content.hbs'),
fallbackDataRecord:
require('./src/templates/fallback-data-record.hbs'),
fallbackDataRecordPreview:
require('./src/templates/fallback-data-record-preview.hbs'),
commandInput: {
main: require('./src/templates/commandInput/main.hbs'),
freetext:
require('./src/templates/commandInput/freetext.hbs'),
tpv_led: require('./src/templates/commandInput/tpv_led.hbs'),
rs232: require('./src/templates/commandInput/rs232.hbs'),
intent: require('./src/templates/commandInput/intent.hbs'),
'intent-extra':
require('./src/templates/commandInput/intent-extra.hbs'),
http: require('./src/templates/commandInput/http.hbs'),
'http-key-value':
require('./src/templates/commandInput/http-key-value.hbs'),
},
},
dataTable: {
buttons: require('./src/templates/dataTable/buttons.hbs'),
multiSelectButton:
require('./src/templates/dataTable/multiselect-button.hbs'),
},
schedule: {
criteriaFields:
require('./src/templates/schedule/schedule-criteria-fields.hbs'),
reminderEvent:
require('./src/templates/schedule/reminder-event.hbs'),
},
calendar: {
day: require('./src/templates/calendar/day.hbs'),
month: require('./src/templates/calendar/month.hbs'),
'month-day': require('./src/templates/calendar/month-day.hbs'),
week: require('./src/templates/calendar/week.hbs'),
'week-days': require('./src/templates/calendar/week-days.hbs'),
year: require('./src/templates/calendar/year.hbs'),
'year-month': require('./src/templates/calendar/year-month.hbs'),
agenda: require('./src/templates/calendar/agenda.hbs'),
agendaFilter: require('./src/templates/calendar/agenda-filter.hbs'),
'agenda-layouts': require('./src/templates/calendar/agenda-layouts.hbs'),
'agenda-displaygroups':
require('./src/templates/calendar/agenda-display-groups.hbs'),
'agenda-campaigns':
require('./src/templates/calendar/agenda-campaigns.hbs'),
'breadcrumb-trail':
require('./src/templates/calendar/breadcrumb-trail.hbs'),
'events-list': require('./src/templates/calendar/events-list.hbs'),
syncEventContentSelector:
require('./src/templates/calendar/sync-event-content-selector.hbs'),
},
display: {
statusWindow: require('./src/templates/display/status-window.hbs'),
},
'multiselect-tag-edit-form':
require('./src/templates/multiselect-tag-edit-form.hbs'),
'auto-submit-field': require('./src/templates/auto-submit-field.hbs'),
'folder-tree': require('./src/templates/folder-tree.hbs'),
'mini-player': require('./src/templates/mini-player.hbs'),
'xibo-filter-clear-button':
require('./src/templates/xibo-filter-clear-button.hbs'),
'php-date-format-table': require('./src/templates/php-date-format-table.hbs'),
campaign: {
campaignAssignLayout:
require('./src/templates/campaign/campaign-assign-layout.hbs'),
},
welcome: {
welcomeCard:
require('./src/templates/welcome/welcome-card.hbs'),
serviceCard:
require('./src/templates/welcome/service-card.hbs'),
othersCard:
require('./src/templates/welcome/others-card.hbs'),
videoModal:
require('./src/templates/welcome/video-modal.hbs'),
videoModalContent:
require('./src/templates/welcome/video-modal-content.hbs'),
},
help: {
mainPanel:
require('./src/templates/help/help-main-panel.hbs'),
feedbackForm:
require('./src/templates/help/help-feedback-form.hbs'),
endPanel:
require('./src/templates/help/help-end-panel.hbs'),
components: {
card:
require('./src/templates/help/components/help-card.hbs'),
listCard:
require('./src/templates/help/components/help-list-card.hbs'),
uploadCard:
require('./src/templates/help/components/help-upload-card.hbs'),
errorMessage:
require('./src/templates/help/components/help-upload-error-msg.hbs'),
header:
require('./src/templates/help/components/help-header.hbs'),
},
},
};

5
ui/bundle_tools.js Normal file
View File

@@ -0,0 +1,5 @@
// --- Add global TOOLS for the CMS ----
window.ArrayHelper = require('./src/helpers/array.js');
window.formHelpers = require('./src/helpers/form-helpers.js');
window.transformer = require('./src/helpers/transformer.js');
window.DateFormatHelper = require('./src/helpers/date-format-helper.js');

144
ui/bundle_vendor.js Normal file
View File

@@ -0,0 +1,144 @@
/*
* 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/>.
*/
// --- Add NPM Packages - JS ----
import './public_path';
// jquery-ui
window.jQuery = window.$ = require('jquery');
// bootstrap
require('bootstrap');
// babel-polyfill
require('babel-polyfill');
// bootbox
window.bootbox = require('bootbox');
// jqueryui resizable, droppable, draggable & sortable
require('jquery-ui/ui/widgets/resizable');
require('jquery-ui/ui/widgets/draggable');
require('jquery-ui/ui/widgets/droppable');
require('jquery-ui/ui/widgets/sortable');
// jquery-validation
require('jquery-validation');
// bootstrap-colorpicker
require('bootstrap-colorpicker');
// momentjs
window.moment = require('moment');
require('moment/min/locales');
// moment-timezone
require('moment-timezone');
try {
// Conditional import for the locale variable
if (CALENDAR_TYPE && CALENDAR_TYPE == 'Jalali') {
// moment-jalaali
window.moment = require('moment-jalaali');
// Persian date time picker
window.persianDate = require('persian-date/dist/persian-date.min.js');
require('persian-datepicker/dist/js/persian-datepicker.min.js');
} else {
// Time/Date picker
require('flatpickr');
window.flatpickrMonthSelectPlugin =
require('flatpickr/dist/plugins/monthSelect/index.js');
try {
// Conditional import for the locale variable
if (jsShortLocale && jsShortLocale != 'en-GB') {
require('flatpickr/dist/l10n/' + jsShortLocale + '.js');
}
} catch (e) { // Handle variable not set error
console.warn(e);
console.warn('[Warning] loading flatpickr: Locale not defined!');
}
}
} catch (e) { // Handle variable not set error
console.warn(e);
console.warn('[Warning] loading moment-jalaali: Calendar Type not defined!');
}
// select2
require('select2');
try {
// Conditional import for the locale variable
if (jsShortLocale && jsShortLocale != 'en-GB' ) {
require('select2/dist/js/i18n/' + jsLocale + '.js');
}
} catch (e) { // Handle variable not set error
console.warn(e);
console.warn('[Warning] loading select2: Locale not defined!');
}
// Default theme for select2
$.fn.select2.defaults.set('theme', 'bootstrap');
// ekko-lightbox
require('ekko-lightbox');
// underscore
window._ = require('underscore/underscore-min.js');
// toastr
window.toastr = require('toastr');
// bootstrap-switch
require('bootstrap-switch');
// bootstrap-slider
require('bootstrap-slider');
// bootstrap-tagsinput
require('bootstrap-tagsinput');
// handlebars
window.Handlebars = require('handlebars/dist/handlebars.min.js');
// colors.js
require('colors.js');
// chart.js
require('chart.js');
window.ChartDataLabels = require('chartjs-plugin-datalabels');
// form-serializer
require('form-serializer');
// --- Add Local JS files ---
// jquery-message-queuing
require('./src/vendor/jquery-message-queuing/jquery.ba-jqmq.min.js');
// typeahead
window.Bloodhound = require('corejs-typeahead/dist/bloodhound.min.js');
require('corejs-typeahead/dist/typeahead.jquery.min.js');
// jsTree
require('jstree/dist/jstree.min.js');
require('jstree/dist/themes/default/style.min.css');

189
ui/bundle_wysiwyg_editor.js Normal file
View File

@@ -0,0 +1,189 @@
/*
* 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/>.
*/
import {
ClassicEditor,
InlineEditor,
AccessibilityHelp,
Alignment,
Autoformat,
AutoImage,
Autosave,
BlockQuote,
Bold,
CloudServices,
Essentials,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
Heading,
HorizontalLine,
ImageBlock,
ImageCaption,
ImageInline,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageTextAlternative,
ImageToolbar,
ImageUpload,
Indent,
IndentBlock,
Italic,
Link,
LinkImage,
List,
ListProperties,
Paragraph,
SelectAll,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
TextTransformation,
TodoList,
Underline,
Undo,
} from 'ckeditor5';
import 'ckeditor5/ckeditor5.css';
const config = {
toolbar: {
items: [
'undo',
'redo',
'fontFamily',
'fontSize',
'fontColor',
'fontBackgroundColor',
'alignment',
'outdent',
'indent',
'|',
'bold',
'italic',
'underline',
'strikethrough',
'subscript',
'superscript',
'|',
'bulletedList',
'numberedList',
'blockQuote',
'insertTable',
'horizontalLine',
'specialCharacters',
'|',
'heading',
],
shouldNotGroupWhenFull: true,
},
plugins: [
AccessibilityHelp,
Alignment,
Autoformat,
AutoImage,
Autosave,
BlockQuote,
Bold,
CloudServices,
Essentials,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
Heading,
HorizontalLine,
ImageBlock,
ImageCaption,
ImageInline,
ImageInsertViaUrl,
ImageResize,
ImageStyle,
ImageTextAlternative,
ImageToolbar,
ImageUpload,
Indent,
IndentBlock,
Italic,
Link,
LinkImage,
List,
ListProperties,
Paragraph,
SelectAll,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableCellProperties,
TableProperties,
TableToolbar,
TextTransformation,
TodoList,
Underline,
Undo,
],
language: 'en-gb',
image: {
toolbar: [
'imageTextAlternative',
'toggleImageCaption',
'imageStyle:inline',
'imageStyle:block',
'imageStyle:side',
],
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells',
'tableProperties',
'tableCellProperties',
],
},
};
ClassicEditor.defaultConfig = config;
InlineEditor.defaultConfig = config;
window.CKEDITOR = {
ClassicEditor,
InlineEditor,
};

38
ui/bundle_xibo.js Normal file
View File

@@ -0,0 +1,38 @@
/*
* 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/>.
*/
// --- Xibo JS files ----
// Xibo forms
require('./src/style/forms.scss');
require('./src/core/forms.js');
// Xibo help
require('./src/core/help-pane.js');
require('./src/style/help-pane.scss');
// Xibo datatables and folders
require('./src/core/xibo-datatables.js');
// Xibo calendar
require('./src/core/xibo-calendar.js');
// Xibo core
require('./src/core/xibo-cms.js');

3
ui/public_path.js Normal file
View File

@@ -0,0 +1,3 @@
const path = document.querySelector('meta[name="public-path"]').content;
// eslint-disable-next-line camelcase
__webpack_public_path__ = path + 'dist/';

2
ui/src/assets/README.md Normal file
View File

@@ -0,0 +1,2 @@
# /ui/src/assets
This folder contains assets that are copied by webpack to the build /dist folder.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,455 @@
/*
* 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/>.
*/
// Import templates
const templateLayoutAddForm =
require('../templates/campaign-builder-layout-add-form-template.hbs');
// Include public path for webpack
require('../../public_path');
require('../style/campaign-builder.scss');
// Campaign builder name space
window.cB = {
$container: null,
$layoutSelect: null,
layoutAssignments: null,
map: null,
initialise: function($container) {
this.$container = $container;
},
initialiseMap: function(containerSelector, $dialog) {
if (this.map !== null) {
this.map.remove();
}
const $containerSelector = $('#' + containerSelector);
const $geoFenceField = $dialog.find('input[name="geoFence"]');
this.map = L.map(containerSelector).setView(
[
this.getDataProperty($containerSelector, 'mapLat', '51'),
this.getDataProperty($containerSelector, 'mapLong', '0.4'),
],
this.getDataProperty($containerSelector, 'mapZoom', 13),
);
L.tileLayer(this.getDataProperty($containerSelector, 'mapTileServer'), {
attribution: this.getDataProperty(
$containerSelector,
'mapAttribution',
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
),
subdomains: ['a', 'b', 'c'],
}).addTo(this.map);
// Add a layer for drawn items
const drawnItems = new L.FeatureGroup();
this.map.addLayer(drawnItems);
// Add draw control (toolbar)
const drawControl = new L.Control.Draw({
position: 'topright',
draw: {
polyline: false,
circle: false,
marker: false,
circlemarker: false,
},
edit: {
featureGroup: drawnItems,
},
});
this.map.addControl(drawControl);
// add search Control - allows searching by country/city and automatically
// moves map to that location
const searchControl = new L.Control.Search({
url: 'https://nominatim.openstreetmap.org/search?format=json&q={s}',
jsonpParam: 'json_callback',
propertyName: 'display_name',
propertyLoc: ['lat', 'lon'],
marker: L.circleMarker([0, 0], {radius: 30}),
autoCollapse: true,
autoType: false,
minLength: 2,
hideMarkerOnCollapse: true,
firstTipSubmit: true,
});
this.map.addControl(searchControl);
// Draw events
this.map.on('draw:created', function(e) {
drawnItems.addLayer(e.layer);
$geoFenceField.val(JSON.stringify(drawnItems.toGeoJSON()));
});
this.map.on('draw:edited', function(e) {
$geoFenceField.val(JSON.stringify(drawnItems.toGeoJSON()));
});
this.map.on('draw:deleted', function(e) {
e.layers.eachLayer(function(layer) {
drawnItems.removeLayer(layer);
});
$geoFenceField.val(JSON.stringify(drawnItems.toGeoJSON()));
});
// Load existing geoJSON
if ($geoFenceField.val()) {
L.geoJSON(JSON.parse($geoFenceField.val()), {
onEachFeature: function(feature, layer) {
drawnItems.addLayer(layer);
cB.map.fitBounds(drawnItems.getBounds());
},
});
}
},
getDataProperty: function($element, property, defaultValue = null) {
const value = $element.data(property);
if (value) {
return value;
} else {
return defaultValue;
}
},
initaliseDisplaySelect: function($selector) {
$selector.select2({
ajax: {
url: $selector.data('searchUrl'),
dataType: 'json',
delay: 250,
data: function(params) {
const query = {
isDisplaySpecific: -1,
forSchedule: 1,
displayGroup: params.term,
start: 0,
length: 10,
columns: [
{
data: 'isDisplaySpecific',
},
{
data: 'displayGroup',
},
],
order: [
{
column: 0,
dir: 'asc',
},
{
column: 1,
dir: 'asc',
},
],
};
// Set the start parameter based on the page number
if (params.page != null) {
query.start = (params.page - 1) * 10;
}
return query;
},
processResults: function(data, params) {
const groups = [];
const displays = [];
$.each(data.data, function(index, element) {
if (element.isDisplaySpecific === 1) {
displays.push({
id: element.displayGroupId,
text: element.displayGroup,
});
} else {
groups.push({
id: element.displayGroupId,
text: element.displayGroup,
});
}
});
let page = params.page || 1;
page = (page > 1) ? page - 1 : page;
return {
results: [
{
text: $selector.data('transGroups'),
children: groups,
}, {
text: $selector.data('transDisplay'),
children: displays,
},
],
pagination: {
more: (page * 10 < data.recordsTotal),
},
};
},
},
});
},
initialiseLayoutSelect: function($selector) {
this.$layoutSelect = $selector;
makePagedSelect($selector);
$selector.on('select2:select', function(e) {
if (!e.params.data) {
return;
}
cB.openLayoutForm({
layoutId: e.params.data.id,
daysOfWeek: '1,2,3,4,5,6,7',
}, campaignBuilderTrans.addLayoutFormTitle);
});
},
openLayoutForm: function(layout, title) {
// Open a modal
// with default vars, layout info and translations
const formHtml = templateLayoutAddForm(
{
...campaignBuilderDefaultVars,
...layout,
...{
trans: campaignBuilderTrans,
},
});
const $dialog = bootbox.dialog({
title: title,
message: formHtml,
size: 'large',
buttons: {
cancel: {
label: campaignBuilderTrans.cancelButton,
className: 'btn-white',
callback: () => {
XiboDialogClose();
},
},
add: {
label: campaignBuilderTrans.saveButton,
className: 'btn-primary save-button',
callback: function() {
$dialog.find('.XiboForm').submit();
return false;
},
},
},
}).on('shown.bs.modal', function() {
// Modal open
const $form = $dialog.find('.XiboForm');
// Init fields
const $daysOfWeek = $dialog.find('select[name="daysOfWeek[]"]');
$.each(layout.daysOfWeek.split(','), function(index, element) {
$daysOfWeek.find('option[value=' + element + ']')
.attr('selected', 'selected');
});
$daysOfWeek.select2({width: '100%'}).val();
const $dayPartId = $dialog.find('select[name="dayPartId"]');
if (layout.dayPartId) {
$dayPartId.data('initial-value', layout.dayPartId);
}
makePagedSelect($dayPartId);
// Load a map
cB.initialiseMap('campaign-builder-map', $dialog);
// Validate form
forms.validateForm(
$dialog.find('.XiboForm'), // form
$dialog, // container
{
submitHandler: function(form) {
XiboFormSubmit($(form), null, () => {
// Is this an add or an edit?
const displayOrder = $form.data('existingDisplayOrder');
if (displayOrder && parseInt(displayOrder) > 0) {
// Delete the existing assignment
$.ajax({
method: 'delete',
url: $form.data('assignmentRemoveUrl') +
'&displayOrder=' + displayOrder,
complete: () => {
refreshLayoutAssignmentsTable();
},
});
} else {
refreshLayoutAssignmentsTable();
}
});
},
},
);
}).on('hidden.bs.modal', function() {
// Clear the layout select
if (cB.$layoutSelect) {
cB.$layoutSelect.val(null).trigger('change');
}
});
},
initialiseLayoutAssignmentsTable: function($selector) {
this.layoutAssignments = $selector.DataTable({
language: dataTablesLanguage,
responsive: true,
dom: dataTablesTemplate,
filter: false,
searchDelay: 3000,
order: [[0, 'asc']],
ajax: {
url: $selector.data('searchUrl'),
dataSrc: function(json) {
if (json && json.data && json.data.length > 0) {
return json.data[0].layouts;
} else {
return [];
}
},
},
columns: [
{
data: 'layoutId',
responsivePriority: 5,
},
{
data: 'layout',
responsivePriority: 1,
},
{
data: 'duration',
responsivePriority: 1,
},
{
data: 'dayPart',
responsivePriority: 1,
},
{
data: 'daysOfWeek',
responsivePriority: 3,
render: function(data) {
if (data) {
const readable = [];
data.split(',').forEach((e) => {
readable.push(campaignBuilderTrans.daysOfWeek[e] || e);
});
return readable.join(', ');
} else {
return '';
}
},
},
{
data: 'geoFence',
responsivePriority: 10,
render: function(data, type) {
if (type !== 'display') {
return !!data;
} else {
return '<i class="fa fa-' + (data ? 'check' : 'times') + '"></i>';
}
},
},
{
data: function(data, type, row, meta) {
const buttons = [
{
id: 'assignment_button_edit',
text: campaignBuilderTrans.assignmentEditButton,
url: '#',
external: false,
class: 'button-assignment-remove',
dataAttributes: [
{
name: 'row-id',
value: meta.row,
},
],
},
{
id: 'assignment_button_delete',
text: campaignBuilderTrans.assignmentDeleteButton,
url: $selector.data('assignmentDeleteUrl') +
'?displayOrder=' + data.displayOrder,
},
];
return dataTableButtonsColumn({buttons: buttons}, type, row, meta);
},
orderable: false,
responsivePriority: 1,
},
],
});
this.layoutAssignments.on('draw', function(e, settings) {
const $target = $('#' + e.target.id);
$target.find('.button-assignment-remove').on('click', function(e) {
e.preventDefault();
const $button = $(e.currentTarget);
if ($button.hasClass('assignment_button_edit')) {
// Open a form.
cB.openLayoutForm(
cB.layoutAssignments.rows($button.data('rowId')).data()[0],
campaignBuilderTrans.editLayoutFormTitle,
);
}
return false;
});
XiboInitialise('#' + e.target.id);
});
},
};
$(function() {
// Get our container
const $container = $('#campaign-builder');
cB.initialise($container);
// Initialise some form controls.
cB.initaliseDisplaySelect(
$container.find('select[name="displayGroupIds[]"]'),
);
cB.initialiseLayoutSelect(
$container.find('select[name="layoutId"]'),
);
cB.initialiseLayoutAssignmentsTable(
$container.find('table#table-campaign-builder-layout-assignments'),
);
});
window.refreshLayoutAssignmentsTable = function() {
// Reload the data table
if (cB.layoutAssignments) {
cB.layoutAssignments.ajax.reload();
}
};

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

View File

@@ -0,0 +1,243 @@
// Load templates
const bottomBarViewerTemplate = require('../templates/bottombar-viewer.hbs');
/**
* Bottom topbar contructor
* @param {object} parent - Parent object
* @param {object} container - the container to render the bottombar to
*/
const Bottombar = function(parent, container) {
this.parent = parent;
this.DOMObject = container;
};
/**
* Render bottombar
* @param {object} object - the object to render the bottombar to
* @param {boolean} renderMultiple
*/
Bottombar.prototype.render = function(object, renderMultiple = true) {
const app = this.parent;
const self = this;
const readOnlyModeOn = (app?.readOnlyMode === true);
let trashBinActive = false;
let multipleSelected = false;
if (typeof object === 'undefined') {
object = this.parent.selectedObject;
}
// Get topbar trans
const newBottomBarTrans =
$.extend({}, toolbarTrans, topbarTrans, bottombarTrans);
const checkHistory = app.checkHistory();
newBottomBarTrans.undoActiveTitle =
(checkHistory) ? checkHistory.undoActiveTitle : '';
// Do we have multiple objects selected
const selectedInViewer = lD.viewer.getMultipleSelected();
if (
renderMultiple &&
selectedInViewer.multiple === true
) {
multipleSelected = true;
trashBinActive = selectedInViewer.canBeDeleted;
newBottomBarTrans.trashBinActiveTitle =
(trashBinActive) ?
newBottomBarTrans.deleteMultipleObjects :
'';
} else {
// Check if trash bin is active
trashBinActive =
app.selectedObject.isDeletable &&
(app?.readOnlyMode === false);
// Get text for bin tooltip
newBottomBarTrans.trashBinActiveTitle =
(trashBinActive) ?
newBottomBarTrans.deleteObject.replace(
'%object%',
app.selectedObject.type,
) :
'';
}
// In interactive mode, do nothing
if (app.interactiveMode || app.interactiveEditWidgetMode) {
this.DOMObject.html('');
return;
}
if (multipleSelected) {
// Render toolbar for multiple
this.DOMObject.html(bottomBarViewerTemplate(
{
trans: newBottomBarTrans,
readOnlyModeOn: readOnlyModeOn,
undoActive: checkHistory.undoActive,
trashActive: trashBinActive,
},
));
} else if (object.type == 'widget') {
// Render widget toolbar
const renderBottomBar = function(templateTitle) {
self.DOMObject.html(bottomBarViewerTemplate(
{
trans: newBottomBarTrans,
readOnlyModeOn: readOnlyModeOn,
object: object,
objectTypeName: newBottomBarTrans.objectType.widget,
moduleTemplateTitle: templateTitle,
undoActive: checkHistory.undoActive,
trashActive: trashBinActive,
},
));
};
// Check if we have datatype
if (object.moduleDataType != '' && object.moduleDataType != undefined) {
// Get template
lD.templateManager.getTemplateById(
object.getOptions().templateId,
object.moduleDataType,
).then((template) => {
renderBottomBar(template.title);
});
} else {
renderBottomBar();
}
} else if (object.type == 'layout') {
// Render layout toolbar
this.DOMObject.html(bottomBarViewerTemplate(
{
trans: newBottomBarTrans,
readOnlyModeOn: readOnlyModeOn,
renderLayout: true,
object: object,
objectTypeName: newBottomBarTrans.objectType.layout,
undoActive: checkHistory.undoActive,
trashActive: trashBinActive,
},
));
// Handle play button ( play or pause )
this.DOMObject.find('#play-btn').on('click', function() {
if (lD.viewer.previewPlaying) {
app.viewer.stopPreview();
} else {
app.viewer.playPreview();
}
});
} else if (object.type == 'region') {
// Render region toolbar
this.DOMObject.html(bottomBarViewerTemplate(
{
trans: newBottomBarTrans,
readOnlyModeOn: readOnlyModeOn,
object: object,
objectTypeName: newBottomBarTrans.objectType[object.subType],
undoActive: checkHistory.undoActive,
trashActive: trashBinActive,
},
));
} else if (
object.type == 'element' ||
object.type == 'element-group'
) {
const widget = lD.getObjectByTypeAndId(
'widget',
'widget_' + object.regionId + '_' + object.widgetId,
'canvas',
);
// If element has media Id or media Name
if (
object.mediaId != undefined || object.mediaName != undefined
) {
// If name is defined, use media Id and name in the tooltip/helper
object.elementMediaInfo = {
name: object.mediaName,
id: object.mediaId,
};
}
// Render element and element group toolbar
this.DOMObject.html(bottomBarViewerTemplate(
{
trans: newBottomBarTrans,
readOnlyModeOn: readOnlyModeOn,
object: object,
widget: widget,
objectTypeName: newBottomBarTrans.objectType[object.type],
undoActive: checkHistory.undoActive,
trashActive: trashBinActive,
},
));
}
// If read only mode is enabled
if (app?.readOnlyMode === true) {
// Create the read only alert message
const $readOnlyMessage =
$('<div id="read-only-message" class="alert alert-warning' +
'text-center navbar-nav" data-container=".editor-bottom-bar"' +
'data-toggle="tooltip" data-placement="bottom" data-title="' +
layoutEditorTrans.readOnlyModeMessage +
'" role="alert"><strong>' + layoutEditorTrans.readOnlyModeTitle +
'</strong>:&nbsp;' + layoutEditorTrans.readOnlyModeMessage + '</div>');
// Prepend the element to the bottom toolbar's content
$readOnlyMessage.insertAfter(this.DOMObject.find('.pull-left'))
.on('click', lD.checkoutLayout);
}
// Button handlers
this.DOMObject.find('#delete-btn').on('click', function() {
lD.deleteSelectedObject();
});
this.DOMObject.find('#undo-btn').on('click', function() {
app.undoLastAction();
});
this.DOMObject.find('.properties-btn').on('click', function(e) {
const buttonData = $(e.currentTarget).data();
let targetObj = object;
if ($(e.currentTarget).hasClass('properties-widget')) {
targetObj = lD.getObjectByTypeAndId(
'widget',
'widget_' + object.regionId + '_' + object.widgetId,
'canvas',
);
}
targetObj.editPropertyForm(
buttonData['property'],
buttonData['propertyType'],
);
});
// Reload tooltips
app.common.reloadTooltips(this.DOMObject);
};
/**
* Show message on play button
*/
Bottombar.prototype.showPlayMessage = function() {
const self = this;
const $target = self.DOMObject.find('#play-btn i');
// Show popover
$target.popover('show');
// Destroy popover after some time
setTimeout(function() {
$target.popover('dispose');
}, 4000);
};
module.exports = Bottombar;

View File

@@ -0,0 +1,48 @@
// CHANGE Module
/**
* A change stores a operation state
*/
/**
* Change object
* @param {number} id -Change id
* @param {string} type -Type of change ( tranform, properties,... )
* @param {string} targetType
* - Target object Type ( widget, region, layout, ... )
* @param {string} targetSubType
* - Target object Sub Type ( canvas, playlist, ... )
* @param {string} targetID - Target object ID
* - Target object ( widget, region, layout, ...
* @param {object} oldState - Previous change properties
* @param {object} newState - Change properties, to be saved
* @param {object} auxTarget - Target to use as comparison as well, (id, type)
*/
const Change = function(
id, type, targetType, targetSubType, targetID, oldState, newState, auxTarget,
) {
this.id = id;
this.type = type;
this.target = {
id: targetID,
type: targetType,
subType: targetSubType,
};
this.oldState = oldState;
this.newState = newState;
this.timeStamp = Math.round((new Date()).getTime() / 1000);
// Flag to check if the change was successfully uploaded
this.uploaded = false;
// Flag to check if the change was already marked for upload
this.uploading = false;
// Skip upload
this.skipUpload = false;
// Aux target - to be used to delete change
// using more than just the Change.target
this.auxTarget = auxTarget;
};
module.exports = Change;

View File

@@ -0,0 +1,302 @@
// COMMON Functions Module
module.exports = {
// Tooltips flag
displayTooltips: true,
// Show delete confirmation modals
deleteConfirmation: true,
/**
* Show loading screen
* @param {string} cloneName - Screen tag
*/
showLoadingScreen: function() {
let bumpVal = $('.loading-overlay.loading').data('bump') || 0;
bumpVal++;
if (bumpVal <= 1) {
$('.loading-overlay').addClass('loading').fadeIn(400);
// TODO: Alert message disabled for now
// it clashes with the user timeout
// window.onbeforeunload = () => editorsTrans.onbeforeunload;
}
$('.loading-overlay').data('bump', bumpVal++);
},
/**
* Hide loading screen
* @param {string} cloneName - Screen tag
*/
hideLoadingScreen: function() {
let bumpVal = $('.loading-overlay.loading').data('bump') || 1;
bumpVal--;
if (bumpVal <= 0) {
$('.loading-overlay.loading').fadeOut(400, function(el) {
$(el).removeClass('loading');
// TODO: Alert message disabled for now
// it clashes with the user timeout
// window.onbeforeunload = null;
});
}
$('.loading-overlay').data('bump', bumpVal);
},
/**
* Refresh (enable/disable) Tooltips
* @param {object} container - Container object
* @param {boolean} forcedOption - Force option
* @param {object =} [options] - Options
* @param {object/boolean} [options.forcedOption = null] - Force option
* @param {object/string=} [options.placement = 'auto']
*/
reloadTooltips: function(
container,
{
forcedOption = null,
placement = 'auto',
} = {},
) {
// Use global var or option
const enableTooltips =
(forcedOption != null) ? forcedOption : this.displayTooltips;
const tooltipSelector = (enableTooltips) ?
'[data-toggle="tooltip"]:not(:disabled)' :
'[data-toggle="tooltip"].tooltip-always-on:not(:disabled)';
// Disable all tooltips first
$(container).find('[data-toggle="tooltip"]').tooltip('dispose');
// Enable tooltips by selector
$(container).find(tooltipSelector).tooltip({
boundary: 'window',
trigger: 'hover',
placement: placement,
});
// Remove rogue/detached tooltips
this.clearTooltips();
},
/**
* Clear Tooltips
*/
clearTooltips: function() {
// Remove rogue/detached tooltips
$('body').find('.tooltip, .popover:not(.tour)').remove();
},
/**
* Format time
* @param {String} timeInSeconds - Time in seconds
* @param {boolean} alwaysShowMinutes - Always show 00:00 even with < 60s
* @return {String} Formatted time
*/
timeFormat: function(
timeInSeconds,
alwaysShowMinutes = true,
) {
const h = Math.floor(timeInSeconds / 3600);
const m = Math.floor(timeInSeconds % 3600 / 60);
const s = Math.floor(timeInSeconds % 3600 % 60);
const zeroBefore = function(time) {
if (time < 10) {
time = '0' + time;
}
return time;
};
const hDisplay = h > 0 ? zeroBefore(h) + ':' : '';
const mDisplay = (m > 0 || hDisplay != '' || alwaysShowMinutes) ?
zeroBefore(m) + ':' : '';
const sDisplay = mDisplay != '' ? zeroBefore(s) : s;
return hDisplay + mDisplay + sDisplay;
},
/**
* Format file size
* @param {String} value - File size in bytes
* @return {String} Formatted file size
*/
formatFileSize: function(value) {
return (
b = Math, c = b.log, d = 1e3, e = c(value) / c(d) | 0, value / b.pow(d, e)
).toFixed(2) + ' ' + (e ? 'kMGTPEZY'[--e] + 'B' : 'Bytes');
},
/**
* Get a module by type
* @param {string} type - Type of media
* @return {object} Module
*/
getModuleByType: function(type) {
return modulesList.find((module) => module.type === type);
},
/**
* Check if object has specific target in data
* @param {object} object - object to check
* @param {string[]} targetType - Target to check
* @return {boolean}
*/
hasTarget: function(object, targetType) {
// Get target data
let targetData = $(object).data('target');
// If target data is not defined, return false
if (targetData == undefined) {
return false;
}
// If target type isn't an array, make it one
if (!Array.isArray(targetData)) {
targetData = targetData.split(' ');
}
// If target is 'all', return true
if (targetData.indexOf('all') !== -1) {
return true;
} else if (
targetData.indexOf(targetType) !== -1
) {
return true;
}
return false;
},
/**
* Clear UI elements from container
* @param {object} $container
*/
clearContainer: function($container) {
// Flatpickr
$container.find('.flatpickr-input').each((_idx, fp) => {
if (fp._flatpickr) {
fp._flatpickr.destroy();
}
});
// Select2
$container.find('select[data-select2-id]')
.select2('destroy');
// Colorpicker
$container.find('.colorpicker-form-element').colorpicker('destroy');
// JqueryUI
$container.is('.ui-droppable') && $container.droppable('destroy');
$container.find('.ui-droppable').droppable('destroy');
$container.find('.ui-draggable').draggable('destroy');
$container.find('.ui-sortable').sortable('destroy');
// Masonry
$container.find('.masonry-container').masonry('destroy');
// CKEditor
$container.find('.rich-text').each((_idx, fp) => {
const richTextId = $(fp).attr('id');
formHelpers.destroyCKEditor(richTextId);
});
},
/**
* Handle minimum dimensions for the editor
* @param {object} editor
* @param {boolean} forceReload
*/
handleEditorMinimumDimensions: function(editor, forceReload) {
const resizeThrottle = 60;
const minWindowWidth = 1200;
const minWindowHeight = 600;
const toolbarLevelLimiter = 1600;
const updateEditor = function() {
// Calculate dimensions
const currentWidth = $(window).width();
const currentHeight = $(window).height();
const editorInitalState = editor.showMinDimensionsMessage;
const toolbarInitalState = editor.toolbar.levelLimiter;
// Check if option is disabled in local storage
const minSizeWarningOff = localStorage.getItem('minSizeWarningOff');
// If editor container is empty object
// stop and detach event handler
if ($.isEmptyObject(editor.editorContainer)) {
$(window).off('resize.' + editor.mainObjectType);
return;
}
// Show editor or message?
editor.showMinDimensionsMessage = (
currentWidth < minWindowWidth ||
currentHeight < minWindowHeight
);
// Limit toolbar
editor.toolbar.levelLimiter = (currentWidth < toolbarLevelLimiter);
// If status changed, refresh editor
if (editorInitalState != editor.showMinDimensionsMessage || forceReload) {
// Show the minimum dimensions message instead
if (
editor.showMinDimensionsMessage &&
!minSizeWarningOff
) {
editor.editorContainer.append(`<div class="min-res-message">
<div>
<h4>${editorsTrans.minDimensionsMessageHeader}</h4>
<div>${editorsTrans.minDimensionsMessageBody}</div>
<button class="close-res-message-button btn btn-outline-primary">
${editorsTrans.minDimensionsMessageHide}
</button>
</div>
</div>`);
// Create overlay
const $customOverlay = $('.custom-overlay').clone();
$customOverlay.removeClass('custom-overlay')
.addClass('custom-overlay-clone min-res-overlay');
$customOverlay.appendTo(editor.editorContainer);
// Click X button to dismiss and save preference
editor.editorContainer.find(
'.min-res-message .close-res-message-button',
).on('click', function() {
editor.minSizeWarningOff = true;
// Save preference to local storage
localStorage.setItem('minSizeWarningOff', true);
// Reload message function to close it
editor.common.handleEditorMinimumDimensions(editor, true);
// Reload viewer if exists
(editor.viewer) && editor.viewer.render();
});
} else {
// Hide message container
editor.editorContainer.find('.min-res-message').remove();
// Remove overlay
editor.editorContainer.find('.min-res-overlay').remove();
}
} else if (toolbarInitalState != editor.toolbar.levelLimiter) {
// If toolbar changed, and we didn't refresh editor, reload it
editor.toolbar.render();
}
};
// Calculate on window resize
$(window).on('resize.' + editor.mainObjectType,
_.debounce(updateEditor, resizeThrottle));
// Calculate on first run
updateEditor();
},
};

View File

@@ -0,0 +1,352 @@
// ELEMENT Module
/**
* Element contructor
* @param {object} data - data from the API request
* @param {number} widgetId - widget id
* @param {number} regionId - region id
* @param {object} parentWidget - parent widget
*/
const ElementGroup = function(data, widgetId, regionId, parentWidget) {
this.widgetId = widgetId;
this.regionId = regionId;
this.type = 'element-group';
// Name
this.elementGroupName = (data.elementGroupName) ? data.elementGroupName : '';
this.id = data.id;
this.left = data.left;
this.top = data.top;
this.width = data.width;
this.height = data.height;
this.rotation = data.rotation;
this.layer = data.layer;
this.elements = {};
// Data slot index
this.slot = data.slot;
this.pinSlot = (data.pinSlot) ? data.pinSlot : false;
// Set element to have same properties for edit and delete as parent widget
this.isEditable = (parentWidget) ? parentWidget.isEditable : true;
// For elements to be deletable, the parent widget also needs to be editable
this.isDeletable = (parentWidget) ?
(
parentWidget.isDeletable &&
parentWidget.isEditable
) : true;
this.isViewable = (parentWidget) ? parentWidget.isViewable : true;
this.effect = data.effect || 'noTransition';
// Expanded on layer manager
this.expanded = false;
this.selected = false;
};
ElementGroup.prototype.updateSlot = function(
slotIndex,
forceUpdate = false,
) {
const self = this;
// If slotIndex is not defined, stop
if (slotIndex === undefined) {
return;
}
if (
!this.slot ||
Number(slotIndex) > this.slot ||
forceUpdate
) {
this.slot = Number(slotIndex);
}
// All element in group use same slot
Object.values(this.elements).forEach((element) => {
element.slot = self.slot;
});
};
ElementGroup.prototype.updatePinSlot = function(
pinSlot,
) {
const self = this;
this.pinSlot = pinSlot;
// All element in group use same slot
Object.values(this.elements).forEach((element) => {
element.pinSlot = self.pinSlot;
});
};
ElementGroup.prototype.updateEffect = function(
effect,
forceUpdate = false,
) {
if (
!this.effect ||
forceUpdate
) {
this.effect = effect;
}
};
ElementGroup.prototype.hasDataType = function() {
const groupElements = Object.values(this.elements);
let hasDataType = false;
for (let index = 0; index < groupElements.length; index++) {
const element = groupElements[index];
if (element.hasDataType) {
hasDataType = true;
break;
}
}
return hasDataType;
};
ElementGroup.prototype.updateGroupDimensions = function(
reload = false,
) {
const self = this;
const groupElements = Object.values(this.elements);
const getRotatedDimensions = function(element, angle) {
// Calculate the sine and cosine of the rotation angle.
const sinA = Math.sin(angle * Math.PI / 180);
const cosA = Math.cos(angle * Math.PI / 180);
// Calculate the new width and height of the rectangle.
const newWidth = element.width * cosA + element.height * sinA;
const newHeight = element.width * sinA + element.height * cosA;
// Calculate the new left and top positions of the rectangle.
const newLeft = element.left + ((element.width - newWidth) / 2);
const newTop = element.top + ((element.height - newHeight) / 2);
return {
width: Math.round(newWidth),
height: Math.round(newHeight),
top: Math.round(newTop),
left: Math.round(newLeft),
};
};
// Reset group properties
self.left = null;
self.top = null;
self.width = null;
self.height = null;
// Update group dimensions based on elements
groupElements.forEach(function(el) {
let elTempProperties = {
left: el.left,
top: el.top,
width: el.width,
height: el.height,
};
// If the element has rotation, get new temporary properties
if (el.rotation && el.rotation != 0) {
elTempProperties = getRotatedDimensions(el, el.rotation);
}
// First we need to find the top/left position
// left needs to adjust to the elements more to the left of the group
if (
self.left === null ||
elTempProperties.left < self.left
) {
self.left = elTempProperties.left;
}
// top needs to adjust to the element more to the top
if (
self.top === null ||
elTempProperties.top < self.top
) {
self.top = elTempProperties.top;
}
});
// Now we need to calculate the width and height
groupElements.forEach(function(el) {
let elTempProperties = {
left: el.left,
top: el.top,
width: el.width,
height: el.height,
};
// If the element has rotation, get new temporary properties
if (el.rotation && el.rotation != 0) {
elTempProperties = getRotatedDimensions(el, el.rotation);
}
if (
self.width === null ||
elTempProperties.left + elTempProperties.width >
self.left + self.width
) {
self.width =
Math.round(elTempProperties.left + elTempProperties.width - self.left);
}
if (
self.height === null ||
elTempProperties.top + elTempProperties.height >
self.top + self.height
) {
self.height =
Math.round(elTempProperties.top + elTempProperties.height - self.top);
}
});
if (reload) {
const widget =
lD.getObjectByTypeAndId(
'widget',
'widget_' + self.regionId + '_' + self.widgetId,
'canvas',
);
// Save JSON with new element into the widget
return widget.saveElements().then((_res) => {
// Reload data and select element when data reloads
lD.reloadData(lD.layout,
{
refreshEditor: true,
});
});
}
return Promise.resolve();
};
/**
* Transform an element group using the new values
* @param {object=} [transform] - Transformation values
* @param {number} [transform.width] - New width (for resize tranformation)
* @param {number} [transform.height] - New height (for resize tranformation)
* @param {number} [transform.top] - New top position (for move tranformation)
* @param {number} [transform.left] - New left position (for move tranformation)
*/
ElementGroup.prototype.transform = function(transform) {
const transformation = {
scaleX: 0,
scaleY: 0,
};
const originalDimensions = {
width: this.width,
height: this.height,
};
// Apply changes to the group ( updating values )
if (transform.width) {
transformation.scaleX = transform.width / this.width;
this.width = transform.width;
}
if (transform.height) {
transformation.scaleY = transform.height / this.height;
this.height = transform.height;
}
if (transform.top) {
this.top = transform.top;
}
if (transform.left) {
this.left = transform.left;
}
const elGroup = this;
// Apply changes to each element of the group
Object.values(this.elements).forEach((el) => {
const elRelativePositionScaled = {
left: (el.left - elGroup.left) * transformation.scaleX,
top: (el.top - elGroup.top) * transformation.scaleY,
};
if (el.groupScale == 1) {
// Scale with the element
el.transform({
width: transformation.scaleX * el.width,
height: transformation.scaleY * el.height,
top: elGroup.top + elRelativePositionScaled.top,
left: elGroup.left + elRelativePositionScaled.left,
});
} else {
// Keep top and left on the same place by default
let newTop = el.top - elGroup.top;
let newLeft = el.left - elGroup.left;
// If bottom bound
if (
el.groupScaleTypeV === 'bottom'
) {
// Distance to bottom
const distToBottom = originalDimensions.height - el.height;
// Calculate top based on bottom position
newTop = newTop - (distToBottom - (elGroup.height - el.height));
} else if (
el.groupScaleTypeV === 'middle'
) {
// Group middle
const groupMiddle = originalDimensions.height / 2;
const newGroupMiddle = elGroup.height /2;
// Element middle
const elMiddle = el.height / 2;
// Distance to middle
const distMiddleToMiddle = groupMiddle - elMiddle;
// Calculate top based on bottom position
newTop = newTop + (newGroupMiddle - distMiddleToMiddle - elMiddle);
}
// If right bound
if (
el.groupScaleTypeH === 'right'
) {
// Calculate left based on right position
newLeft = newLeft - (originalDimensions.width - elGroup.width);
} else if (
el.groupScaleTypeV === 'middle'
) {
// Group center
const groupCenter = originalDimensions.width / 2;
const newGroupCenter = elGroup.width /2;
// Element center
const elCenter = el.width / 2;
// Distance to center
const distCenterToCenter = groupCenter - elCenter;
// Calculate top based on bottom position
newLeft = newLeft + (newGroupCenter - distCenterToCenter - elCenter);
}
// Transform without scaling
el.transform({
top: elGroup.top + newTop,
left: elGroup.left + newLeft,
});
}
});
};
module.exports = ElementGroup;

View File

@@ -0,0 +1,326 @@
// ELEMENT Module
/**
* Element contructor
* @param {object} data - data from the API request
* @param {number} widgetId - widget id
* @param {number} regionId - region id
* @param {object} parentWidget - parent widget
*/
const Element = function(data, widgetId, regionId, parentWidget) {
this.type = 'element';
this.widgetId = widgetId;
this.regionId = regionId;
this.groupId = data.groupId;
// Name
this.elementName = (data.elementName) ? data.elementName : '';
// If group id is set, grab group properties
if (this.groupId) {
this.groupProperties = data.groupProperties;
this.group = {};
}
this.id = data.id;
this.elementId = data.elementId;
this.elementType = (data.elementType) ? data.elementType : data.type;
// has data type ( if it's not global )
this.hasDataType = (data.type != 'global');
this.left = data.left;
this.top = data.top;
this.width = data.width;
this.height = data.height;
this.rotation = data.rotation;
this.layer = data.layer;
this.properties = data.properties;
// Set element to have same properties for edit and delete as parent widget
this.isEditable = (parentWidget) ? parentWidget.isEditable : true;
// For elements to be deletable, the parent widget also needs to be editable
this.isDeletable = (parentWidget) ?
(
parentWidget.isDeletable &&
parentWidget.isEditable
) : true;
this.isViewable = (parentWidget) ? parentWidget.isViewable : true;
// Check if the element is visible on rendering ( true by default )
this.isVisible = (data.isVisible === undefined) ? true : data.isVisible;
// Element data from the linked widget/module
this.data = {};
// Element template
this.template = {};
// Can rotate?
this.canRotate = false;
// Data slot index
this.slot = data.slot;
this.pinSlot = (data.pinSlot) ? data.pinSlot : false;
// Group scale
this.groupScale = (data.groupScale != undefined) ?
data.groupScale : 1;
this.groupScaleTypeV = (data.groupScaleTypeV != undefined) ?
data.groupScaleTypeV : 'top';
this.groupScaleTypeH = (data.groupScaleTypeH != undefined) ?
data.groupScaleTypeH : 'left';
// Animation effect
this.effect = data.effect || 'noTransition';
// Media id and name
this.mediaId = data.mediaId;
this.mediaName = data.mediaName;
this.selected = false;
};
/**
* Get the element properties (merged with template properties)
* @return {Promise} - Element properties array
*/
Element.prototype.getProperties = function() {
const self = this;
return new Promise(function(resolve, reject) {
self.getTemplate().then((template) => {
// Create a full copy of the template object
// (we don't want to modify the original template)
const templateCopy = JSON.parse(JSON.stringify(template));
// If type is wrong, or not defined, change it to the template's
if (
typeof self.elementType === 'undefined' ||
template.dataType != self.elementType
) {
self.elementType = template.dataType;
}
// Merge template properties with element properties
if (templateCopy != undefined) {
for (let j = 0; j < templateCopy.properties.length; j++) {
const property = templateCopy.properties[j];
// If we have a value for the property, set it
if (self.properties) {
for (let i = 0; i < self.properties.length; i++) {
const elementProperty = self.properties[i];
if (elementProperty.id === property.id) {
property.value = elementProperty.value;
property.default = elementProperty.default;
}
}
}
// If value is unset and we have default, use it instead
// Make replacements for the default value
// if we have any special value
if (String(property.default).match(/%(.*?)%/gi)) {
const placeholder = property.default.slice(1, -1);
switch (placeholder) {
case 'THEME_COLOR':
property.default = lD.viewer.themeColors[lD.viewer.theme];
break;
default:
break;
}
}
if (property.value == undefined) {
property.value = property.default;
}
}
// Check if element has rotation and set it
if (templateCopy.canRotate) {
self.canRotate = templateCopy.canRotate;
}
}
// Update the element properties
self.properties = templateCopy.properties;
// Return the element properties in a promise
resolve(self.properties);
});
});
};
/**
* Get template
* @return {Promise} - Promise that resolves when the template is loaded
*/
Element.prototype.getTemplate = function() {
const self = this;
return new Promise(function(resolve, reject) {
// If the template is already loaded, resolve the promise
if (self.template.templateId != undefined) {
resolve(self.template);
} else {
lD.templateManager.getTemplateById(
self.id,
self.elementType,
).then((template) => {
// If template is an extention of another template
// load the parent template
if (template.extends) {
lD.templateManager.getTemplateById(
template.extends.template,
'global',
).then((parentTemplate) => {
// Save the template only after we get the parent
self.template = template;
// Merge the parent template properties with the template properties
// (if the template has a property with the same id as the parent
// template, use the template's property instead)
self.template.parent = parentTemplate;
const newProperties = [];
// Loop through parent template properties
for (let i = 0; i < parentTemplate.properties.length; i++) {
const parentProperty = parentTemplate.properties[i];
let found = false;
// If property is the one in overrides, don't add it
if (template.extends?.override == parentProperty.id) {
continue;
}
// Loop through template properties
for (let j = 0; j < template.properties.length; j++) {
const property = template.properties[j];
// If we have a property with the same id, use the template's
if (property.id === parentProperty.id) {
found = true;
break;
}
}
// If we didn't find a property with the same id, add it
if (!found) {
newProperties.push(parentProperty);
}
}
// Add the new properties to the template
self.template.properties =
template.properties.concat(newProperties);
// If template doesn't have onTemplateRender, use parent's
if (!template.onTemplateRender) {
template.onTemplateRender = parentTemplate.onTemplateRender;
} else {
// If onTemplateRender has the "callParent" placeholder,
// replace it with the parent's onTemplateRender
if (
template.onTemplateRender &&
template.onTemplateRender.includes('callParent')) {
template.onTemplateRender = template.onTemplateRender
.replace('%callParent%', parentTemplate.onTemplateRender);
}
}
return resolve(self.template);
});
} else {
// Save the template
self.template = template;
// Resolve the promise
resolve(template);
}
});
}
});
};
/**
* Transform an element using the new values
* @param {object=} [transform] - Transformation values
* @param {number} [transform.width] - New width (for resize tranformation)
* @param {number} [transform.height] - New height (for resize tranformation)
* @param {number} [transform.top] - New top position (for move tranformation)
* @param {number} [transform.left] - New left position (for move tranformation)
* @param {number} [transform.rotation] - New rotation
*/
Element.prototype.transform = function(transform) {
// Apply changes to the element ( updating values )
(transform.width) && (this.width = transform.width);
(transform.height) && (this.height = transform.height);
(transform.top) && (this.top = transform.top);
(transform.left) && (this.left = transform.left);
(transform.rotation) && (this.rotation = transform.rotation);
};
/**
* Get linked widget data
* @return {Promise} - Promise with widget data
*/
Element.prototype.getData = function() {
const self = this;
const parentWidget = lD.getObjectByTypeAndId(
'widget',
'widget_' + this.regionId + '_' + this.widgetId,
'canvas',
);
return new Promise(function(resolve, reject) {
// If element already has data, use cached data
if (
self.elementType === 'global'
) {
resolve();
} else {
const slot = self.slot ? self.slot : 0;
const loaderTargetId = (self.groupId) ?
self.groupId : self.elementId;
// Show loader on element or group
lD.viewer.toggleLoader(loaderTargetId, true);
parentWidget.getData().then(({data, meta}) => {
// Show loader on element or group
lD.viewer.toggleLoader(loaderTargetId, false);
// Resolve the promise with the data
// If slot is outside the data array
// restart from 0
resolve({data: data[slot % data.length], meta});
});
}
});
};
/**
* Replace media in element
* @param {string} mediaId
* @param {string} mediaName
* @return {Promise} - Promise with widget data
*/
Element.prototype.replaceMedia = function(mediaId, mediaName) {
const self = this;
const parentWidget = lD.getObjectByTypeAndId(
'widget',
'widget_' + this.regionId + '_' + this.widgetId,
'canvas',
);
// Replace media id
self.mediaId = mediaId;
self.mediaName = mediaName;
return parentWidget.saveElements();
};
module.exports = Element;

View File

@@ -0,0 +1,571 @@
/*
* 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/>.
*/
/* eslint-disable prefer-promise-reject-errors */
/**
* History manager, that stores all the changes and
* operations that can be applied to them (upload/revert)
*/
const Change = require('../editor-core/change.js');
const managerTemplate = require('../templates/history-manager.hbs');
// Map from a operation to its inverse, and
// detail if the operation is done on the object or the layout
const inverseChangeMap = {
transform: {
inverse: 'transform',
parseData: true,
},
create: {
inverse: 'delete',
},
saveForm: {
inverse: 'saveForm',
},
addMedia: {
inverse: 'delete',
},
addWidget: {
inverse: 'delete',
},
order: {
inverse: 'order',
},
saveElements: {
inverse: 'saveElements',
},
};
/**
* History Manager
* @param {object} parent - Parent object
* @param {object} container - Container to append the manager to
* @param {bool} visible - Show the manager
*/
const HistoryManager = function(parent, container, visible) {
this.parent = parent;
this.DOMObject = container;
this.extended = true;
this.visible = visible;
this.changeUniqueId = 0;
this.changeHistory = []; // Array of changes
this.toggleExtended = function() {
this.extended = !this.extended;
// Render template container
this.render();
};
// Return true if there are some not uploaded changes
this.changesToUpload = function() {
for (let index = this.changeHistory.length - 1; index >= 0; index--) {
if (!this.changeHistory[index].uploaded) {
return true;
}
}
return false;
};
};
/**
* Save a editor change to the history array
* @param {string} changeType -Type of change ( resize, move )
* @param {string} targetType - Target object Type
* ( widget, region, layout, ... )
* @param {string} targetId - Target object ID
* @param {object=} [oldValues] - Previous object change
* @param {object=} [newValues] - New object change
* @param {object =} [options] - Manager options
* @param {bool=} [options.upload = true] - Upload change in real time
* @param {bool=} [options.addToHistory = true]
* - Add change to the history array
* @param {bool=} [options.updateTargetId = false]
* - Update change target id with the one returned from the API on upload
* @param {string=} [options.updateTargetType = null]
* - Update change target type after upload
* with the value passed on this variable
* @param {object=} [options.customRequestPath = null]
* - Custom Request Path ( url and type )
* @param {object=} [options.customRequestReplace = null]
* - Custom Request replace ( tag and replace )
* @return {Promise} - Promise that resolves when the change is uploaded
*/
HistoryManager.prototype.addChange = function(
changeType, targetType, targetId, oldValues, newValues,
{
upload = true,
addToHistory = true,
updateTargetId = false,
updateTargetType = null,
customRequestPath = null,
customRequestReplace = null,
targetSubType = null,
skipUpload = false,
auxTarget = {},
} = {},
) {
const changeId = this.changeUniqueId++;
// create new change and add it to the array
const newChange = new Change(
changeId,
changeType,
targetType,
targetSubType,
targetId,
oldValues,
newValues,
auxTarget,
);
// If we want to skip upload and only use it for revert locally
newChange.skipUpload = skipUpload;
// If we skip upload, mark it as uploaded
if (skipUpload) {
newChange.uploaded = true;
}
// Add change to the history array
if (addToHistory) {
this.changeHistory.push(newChange);
// Render template container
this.render();
}
// Upload change
if (upload) {
return this.uploadChange(
newChange,
updateTargetId,
updateTargetType,
customRequestPath,
customRequestReplace,
);
} else {
return Promise.resolve('Change added!');
}
};
/**
* Upload first change in the history array
* @param {object} change
* @param {bool=} updateId
* @param {string=} updateType
* @param {object=} customRequestPath
* @param {object=} customRequestReplace
* @return {Promise} - Promise that resolves when the change is uploaded
*/
HistoryManager.prototype.uploadChange = function(
change,
updateId,
updateType,
customRequestPath,
customRequestReplace,
) {
const self = this;
const app = this.parent;
// Test for empty history array
if (!change || change.uploaded) {
return Promise.reject('Change already uploaded!');
}
change.uploading = true;
const linkToAPI =
(customRequestPath != null) ?
customRequestPath :
urlsForApi[change.target.type][change.type];
let requestPath = linkToAPI.url;
// Custom replace tag
if (customRequestReplace) {
requestPath =
requestPath.replace(
customRequestReplace.tag,
customRequestReplace.replace,
);
}
// replace id if necessary/exists
if (change.target) {
let replaceId = change.target.id;
const replaceType = change.target.type;
// If the replaceId is not set or the change needs the main object Id
if (replaceId == null || linkToAPI.useMainObjectId) {
replaceId = app.mainObjectId;
}
requestPath = requestPath.replace(':id', replaceId);
requestPath = requestPath.replace(':type', replaceType);
}
// Run ajax request and save promise
return new Promise(function(resolve, reject) {
$.ajax({
url: requestPath,
type: linkToAPI.type,
data: change.newState,
}).done(function(data) {
if (data.success) {
change.uploaded = true;
change.uploading = false;
// Update the Id of the change with the new object
if (updateId) {
if (change.type === 'create' || change.type === 'addWidget') {
change.target.id = data.id;
} else if (change.type === 'addMedia') {
change.target.id = [];
for (let index = 0; index < data.data.newWidgets.length; index++) {
change.target.id.push(data.data.newWidgets[index].widgetId);
}
}
}
// If set: Update the type of change to enable the revert
if (updateType != null) {
change.target.type = updateType;
}
// Resolve promise
resolve(data);
} else {
// Login Form needed?
if (data.login) {
window.location.reload();
} else {
// Just an error we dont know about
if (data.message == undefined) {
reject(data);
} else {
reject(data.message);
}
}
}
// Render/Update Manager
self.render();
}).fail(function(jqXHR, textStatus, errorThrown) {
// Output error to console
console.error(jqXHR, textStatus, errorThrown);
// Reject promise and return an object with all values
reject({jqXHR, textStatus, errorThrown});
});
});
};
/**
* Revert change by ID or the last one in the history array
* @return {Promise} - Promise that resolves when the change is reverted
*/
HistoryManager.prototype.revertChange = function() {
// Prevent trying to revert if there are no changes in history
if (this.changeHistory.length <= 0) {
return Promise.reject('There are no changes in history!');
}
const self = this;
const app = this.parent;
// Get the last change in the array
const lastChange = this.changeHistory[this.changeHistory.length - 1];
const inverseOperation = inverseChangeMap[lastChange.type].inverse;
const parseData = inverseChangeMap[lastChange.type].parseData;
return new Promise(function(resolve, reject) {
// Revert element save
if (lastChange.type === 'saveElements') {
const widget =
lD.getObjectByTypeAndId('widget', lastChange.target.id, 'canvas');
try {
// Get elements from previous state
const elementsToSave = (lastChange.oldState === '') ?
[] :
JSON.parse(lastChange.oldState)[0].elements;
// Save elements to widget ( without saving to history )
widget.saveElements({
elements: elementsToSave,
addToHistory: false,
updateEditor: true,
}).then(function() {
// Remove change from history
self.removeLastChange();
resolve({
localRevert: true,
});
});
} catch (e) {
console.error('parseElementFromWidget', e);
return;
}
} else if (!lastChange.uploaded) {
// Revert on the client side ( non uploaded change )
// Get data to apply
let data = lastChange.oldState;
// Get object by type,from the main object
const object = app.getObjectByTypeAndId(
lastChange.target.type, // Type
lastChange.target.type + '_' + lastChange.target.id, // Id
);
// If the operation needs data parsing
if (parseData != undefined && parseData) {
data = JSON.parse(data.regions)[0];
}
// Apply inverse operation to the object
object[inverseOperation](data, false);
// Remove change from history
self.removeLastChange();
resolve({
type: inverseOperation,
target: lastChange.target.type,
message: 'Change reverted',
localRevert: true,
});
} else { // Revert using the API
const linkToAPI = urlsForApi[lastChange.target.type][inverseOperation];
const revertObject = function() {
let requestPath = linkToAPI.url;
// replace id if necessary/exists
if (lastChange.target) {
let replaceId = '';
if (Array.isArray(lastChange.target.id)) {
replaceId = lastChange.target.id[0];
lastChange.target.id.shift();
} else {
replaceId = lastChange.target.id;
}
// If the replaceId is not set or the change needs
// the layoutId, set it to the replace var
if (replaceId == null || linkToAPI.useMainObjectId) {
replaceId = app.mainObjectId;
}
requestPath = requestPath.replace(':id', replaceId);
}
$.ajax({
url: requestPath,
type: linkToAPI.type,
data: lastChange.oldState,
}).done(function(data) {
if (data.success) {
// If the target is a int or if it's an array
// with no elements, resolve method
if (
(
Array.isArray(lastChange.target.id) &&
lastChange.target.id.length == 0
) ||
!isNaN(lastChange.target.id)
) {
// Remove change from history
self.removeLastChange();
// If the operation is a deletion, unselect object before deleting
if (inverseOperation === 'delete') {
// Unselect selected object before deleting
app.selectObject();
}
// Resolve promise
resolve(data);
} else {
// Revert next change
revertObject();
}
} else {
// Login Form needed?
if (data.login) {
window.location.reload();
} else {
// Just an error we dont know about
if (data.message == undefined) {
reject(data);
} else {
reject(data.message);
}
}
}
}).fail(function(jqXHR, textStatus, errorThrown) {
// Output error to console
console.error(jqXHR, textStatus, errorThrown);
// Reject promise and return an object with all values
reject({jqXHR, textStatus, errorThrown});
});
};
revertObject();
}
});
};
/**
* Save all the changes in the history array
*/
HistoryManager.prototype.saveAllChanges = async function() {
const self = this;
// stop method if there are no changes to be saved
if (!this.changesToUpload()) {
return Promise.resolve('No changes to upload');
}
const promiseArray = [];
for (let index = 0; index < self.changeHistory.length; index++) {
const change = self.changeHistory[index];
// skip already uploaded changes or skipped
if (change.uploaded || change.uploading || change.skipUpload) {
continue;
}
change.uploading = true;
promiseArray.push(await self.uploadChange(change));
// Render manager container to update the change
self.render();
}
return Promise.all(promiseArray);
};
/**
* Remove all the changes in the history array related to a specific object
* @param {string} targetType - Target object Type
* ( widget, region, layout, ... )
* @param {string} targetId - Target object ID
* @return {Promise} - Promise that resolves when the changes are removed
*/
HistoryManager.prototype.removeAllChanges = function(targetType, targetId) {
const self = this;
return new Promise(function(resolve, reject) {
for (let index = 0; index < self.changeHistory.length; index++) {
const change = self.changeHistory[index];
const isTarget = (
change.target.type === targetType &&
(
change.target.id === targetId ||
(
Array.isArray(change.target.id) &&
change.target.id.includes(targetId)
)
)
);
const isAuxTarget = (
change.auxTarget &&
change.auxTarget.type === targetType &&
(
change.auxTarget.id === targetId ||
(
Array.isArray(change.auxTarget.id) &&
change.auxTarget.id.includes(targetId)
)
)
);
if (isTarget || isAuxTarget) {
self.changeHistory.splice(index, 1);
// When change is removed, we need to decrement the index
index--;
}
}
// Render template container
self.render();
resolve('All Changes Removed');
});
};
/**
* Remove last change
*/
HistoryManager.prototype.removeLastChange = function() {
this.changeHistory.pop();
this.render();
};
/**
* Render Manager
* @param {boolean} reloadToolbar - force render toolbar?
*/
HistoryManager.prototype.render = function(
reloadToolbar = true,
) {
// Upload bottom bar if exists
if (this.parent.bottombar && reloadToolbar) {
this.parent.bottombar.render();
}
if (this.visible == false) {
return;
}
// Compile layout template with data
const html = managerTemplate(this);
// Append layout html to the main div
this.DOMObject.html(html);
// Make the history div draggable
this.DOMObject.draggable();
// Add toggle visibility event
this.DOMObject.find('#layout-manager-header .title')
.click(this.toggleExtended.bind(this));
};
module.exports = HistoryManager;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
/*
* 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/>.
*/
// Load templates
const topbarTemplate = require('../templates/topbar.hbs');
const topbarLayoutJumpList =
require('../templates/toolbar-layout-jump-list.hbs');
/**
* Bottom topbar contructor
* @param {object} parent - The parent object
* @param {object} container - the container to render the topbar to
* @param {object[]} [customDropdownOptions] - customized dropdown buttons
* @param {object} [customActions] - customized actions
* @param {object} [jumpList] - jump list
* @param {boolean=} [showOptions] - show options menu
*/
const Topbar = function(parent,
container,
customDropdownOptions = null,
customActions = {},
jumpList = {},
showOptions = false) {
this.parent = parent;
this.DOMObject = container;
// Layout jumplist
this.jumpList = jumpList;
// Custom dropdown buttons
this.customDropdownOptions = customDropdownOptions;
// Custom actions
this.customActions = customActions;
// Options menu
this.showOptions = showOptions;
};
/**
* Render topbar
*/
Topbar.prototype.render = function() {
const self = this;
const app = this.parent;
// Get main object
const mainObject =
app.getObjectByTypeAndId(app.mainObjectType, app.mainObjectId);
// Format duration
mainObject.duration = Math.round(Number(mainObject.duration) * 100) / 100;
// Get topbar trans
const newTopbarTrans = $.extend({}, toolbarTrans, topbarTrans, editorsTrans);
// Clear temp data
app.common.clearContainer(this.DOMObject);
// Compile layout template with data
const html = topbarTemplate({
customDropdownOptions: this.customDropdownOptions,
displayTooltips: app.common.displayTooltips,
deleteConfirmation: app.common.deleteConfirmation,
trans: newTopbarTrans,
mainObject: mainObject,
showOptions: self.showOptions,
exitURL: (lD != 'undefined') && lD.exitURL,
});
// Append layout html to the main div
this.DOMObject.html(html);
const setButtonActionAndState = function(button) {
if (button.isDivider) {
return;
}
let buttonInactive = false;
// Bind action to button
self.DOMObject.find('#' + button.id).click((ev) => {
const $btn = $(ev.currentTarget);
if (
!$btn.hasClass('disabled') &&
typeof button.action === 'function'
) {
button.action();
$btn.addClass('disabled');
setTimeout(() => $btn.removeClass('disabled'), 200);
}
});
// If there is a inactiveCheck, use that function to switch button state
if (button.inactiveCheck != undefined) {
const inactiveClass =
(button.inactiveCheckClass != undefined) ?
button.inactiveCheckClass :
'disabled';
const toggleValue = button.inactiveCheck();
self.DOMObject.find('#' + button.id)
.toggleClass(inactiveClass, toggleValue);
buttonInactive = toggleValue;
}
return buttonInactive;
};
// Setup layout edit form.
this.DOMObject.find('#layoutInfo').off('click').on('click', function() {
// If in interactive edit mode, don't open form
if (app.interactiveEditWidgetMode) {
return;
}
// Pop open the layout edit form.
XiboFormRender(urlsForApi.layout.editForm.url.replace(
':id',
self.parent.layout.parentLayoutId || self.parent.layout.layoutId),
);
/**
* Wait for name field to show and select it
* so it can be easier to replace
*/
function selectField() {
const $field = $('#layoutEditForm input#name');
// If field doesn't exist, wait and call method again
if ($field.length === 0) {
setTimeout(selectField, 200);
} else {
// Select name
$field.trigger('select');
}
}
selectField();
});
// Handle custom dropwdown buttons
if (this.customDropdownOptions != null) {
let activeDropdown = false;
for (let index = 0; index < this.customDropdownOptions.length; index++) {
const buttonInactive =
setButtonActionAndState(this.customDropdownOptions[index]);
if (!buttonInactive) {
activeDropdown = true;
}
}
self.DOMObject.find('.dropdown.navbar-submenu:not(.navbar-submenu-options)')
.toggle(activeDropdown);
}
// Set layout jumpList if exists
if (
!$.isEmptyObject(this.jumpList) &&
self.DOMObject.find('#layoutJumpList').length == 0
) {
self.setupJumpList();
}
// Options menu
if (self.showOptions) {
self.DOMObject.find('.navbar-submenu-options-container').off()
.on('click', function(e) {
e.stopPropagation();
});
// Toggle tooltips
self.DOMObject.find('#displayTooltips').off().on('click', function() {
app.common.displayTooltips = $('#displayTooltips').prop('checked');
app.toolbar.savePrefs();
app.common.reloadTooltips(app.editorContainer);
});
// Show delete confirmation modals
self.DOMObject.find('#deleteConfirmation').off().on('click', function() {
app.common.deleteConfirmation = $('#deleteConfirmation').prop('checked');
app.toolbar.savePrefs();
});
}
// Interactive control ( design pending )
const toggleControl = function(enable = true) {
self.DOMObject.find('.interactive-control').attr('data-status',
(enable) ? 'on' : 'off');
};
// Handle toggle button
self.DOMObject.find('.interactive-control')
.off().on('click', function() {
app.toggleInteractiveMode(!app.interactiveMode);
toggleControl(app.interactiveMode);
});
// Call on start
toggleControl(app.interactiveMode);
// Update layout status
this.updateLayoutStatus();
// Reload tooltips
app.common.reloadTooltips(self.DOMObject);
};
/**
* Setup layout jumplist
*/
Topbar.prototype.setupJumpList = function() {
const app = this.parent;
const $jumpListContainer = $('#layoutJumpListContainer');
// If we are in template edit mode, don't show
if (
this.parent.templateEditMode != undefined &&
this.parent.templateEditMode === true
) {
return;
}
const html = topbarLayoutJumpList(this.jumpList);
// Clear temp data
app.common.clearContainer($jumpListContainer);
// Append layout html to the main div
$jumpListContainer.html(html);
$jumpListContainer.removeClass('d-none');
const jumpList = $jumpListContainer.find('#layoutJumpList');
jumpList.select2({
ajax: {
url: jumpList.data().url,
dataType: 'json',
delay: 250,
data: function(params) {
const query = {
layout: params.term,
onlyMyLayouts: $('#onlyMyLayouts').is(':checked'),
retired: 0,
start: 0,
length: 10,
};
localStorage.liveSearchOnlyMyLayouts =
$('#onlyMyLayouts').is(':checked') ? 1 : 0;
// Tags
if (query.layout != undefined) {
const tags = query.layout.match(/\[([^}]+)\]/);
if (tags != null) {
// Add tags to search
query.tags = tags[1];
// Replace tags in the query text
query.layout = query.layout.replace(tags[0], '');
}
}
// Set the start parameter based on the page number
if (params.page != null) {
query.start = (params.page - 1) * 10;
}
// Find out what is inside the search box for this list
// and save it (so we can replay it when the list
// is opened again)
if (params.term !== undefined) {
localStorage.liveSearchPlaceholder = params.term;
}
return query;
},
processResults: function(data, params) {
const results = [];
$.each(data.data, function(index, element) {
results.push({
id: element.layoutId,
text: element.layout,
});
});
let page = params.page || 1;
page = (page > 1) ? page - 1 : page;
return {
results: results,
pagination: {
more: (page * 10 < data.recordsTotal),
},
};
},
},
});
jumpList.on('select2:select', function(e) {
// OPTIMIZE: Maybe use the layout load without reloading page
// self.jumpList.callback(e.params.data.id);
// Go to the Layout we've selected.
window.location = jumpList.data().designerUrl
.replace(':id', e.params.data.id);
}).on('select2:opening', function(e) {
// Set the search box according to the saved value (if we have one)
if (
localStorage.liveSearchPlaceholder != null &&
localStorage.liveSearchPlaceholder !== ''
) {
const $search = jumpList.data('select2').dropdown.$search;
$search.val(localStorage.liveSearchPlaceholder);
setTimeout(function() {
$search.trigger('input');
}, 100);
}
}).on('select2:open', function(e) {
// append checkbox after select2 search input (only once)
if ($('#onlyMyLayouts').length === 0) {
$('<input style=\'margin-left: 5px; margin-bottom: 15px\' ' +
'type=\'checkbox\' id=\'onlyMyLayouts\' name=\'onlyMyLayouts\'> ' +
topbarTrans.onlyMyLayouts + '</input>')
.insertAfter('.select2-search');
// if checkbox was checked last time, check it now
if (Number(localStorage.getItem('liveSearchOnlyMyLayouts')) === 1) {
$('#onlyMyLayouts').prop('checked', true);
}
}
const $search = jumpList.data('select2').dropdown.$search;
// when checkbox state is changed trigger the select2 to make a new search
$('#onlyMyLayouts').on('change', function(e) {
setTimeout(function() {
$search.trigger('input');
}, 100);
});
// Force search field focus
setTimeout(function() {
$search.get(0).focus();
}, 10);
});
};
/**
* Update layout status in the info fields
*/
Topbar.prototype.updateLayoutStatus = function() {
const statusContainer = this.DOMObject.find('#layout-info-status');
// Use status loader icon
statusContainer.find('i').removeClass().addClass('fa fa-spinner fa-spin');
statusContainer.removeClass().addClass('badge badge-default');
// Prevent the update if there's no layout status yet
if (lD.layout.status == undefined) {
return;
}
let title = '';
let content = '';
const labelCodes = {
1: 'success',
2: 'warning',
3: 'info',
'': 'danger',
};
const iconCodes = {
1: 'check',
2: 'exclamation',
3: 'cogs',
'': 'times',
};
// Create title and description
if (lD.layout.status.messages.length > 0) {
title = lD.layout.status.description;
for (let index = 0; index < lD.layout.status.messages.length; index++) {
content += '<div class="status-message">' +
lD.layout.status.messages[index] +
'</div>';
}
} else {
title = '';
content = '<div class="status-title text-center">' +
lD.layout.status.description +
'</div>';
}
// Update label
const labelType = (labelCodes[lD.layout.status.code] != undefined) ?
labelCodes[lD.layout.status.code] :
labelCodes[''];
statusContainer.removeClass().addClass('badge badge-' + labelType)
.attr('data-status-code', lD.layout.status.code);
// Create or update popover
if (statusContainer.data('bs.popover') == undefined) {
// Create popover
statusContainer.popover(
{
delay: tooltipDelay,
title: title,
content: content,
},
);
} else {
// Update popover
statusContainer.data('bs.popover').config.title = title;
statusContainer.data('bs.popover').config.content = content;
}
// Change Icon
const iconType = (iconCodes[lD.layout.status.code] != undefined) ?
iconCodes[lD.layout.status.code] :
iconCodes[''];
statusContainer.find('i').removeClass().addClass('fa fa-' + iconType);
// Update duration
this.DOMObject.find('.layout-info-duration-value').html(
lD.common.timeFormat(
lD.layout.duration,
));
};
module.exports = Topbar;

1820
ui/src/editor-core/widget.js Normal file

File diff suppressed because it is too large Load Diff

35
ui/src/helpers/array.js Normal file
View File

@@ -0,0 +1,35 @@
const ArrayHelper = function() {
return {
/**
* Function to move item of an array by index
* @param arr {Array} - Input array
* @param from {number} - Original array index
* @param to {number} - New array index
* @return {Array}
*/
move: function(arr, from, to) {
if (arr === undefined) {
console.warn('Please provide a valid array parameter');
return [];
}
if (arr.length === 0) {
return arr;
}
// Check if indexes: from and to are within the array
if (from >= arr.length || to >= arr.length) {
return arr;
}
// Store arr[from]
const temp = arr[from];
arr.splice(from, 1);
arr.splice(to, 0, temp);
return arr;
},
};
};
module.exports = new ArrayHelper([]);

View File

@@ -0,0 +1,97 @@
const DateFormatHelper = function(options) {
this.timezone = null;
this.systemFormat = 'Y-m-d H:i:s';
this.macroRegex = /^%(\+|\-)[0-9]([0-9])?(d|h|m|s)%$/gi;
this.convertPhpToMomentFormat = function(format) {
if (String(format).length === 0) {
return '';
}
const replacements = {
d: 'DD',
D: 'ddd',
j: 'D',
l: 'dddd',
N: 'E',
S: 'o',
w: 'e',
z: 'DDD',
W: 'W',
F: 'MMMM',
m: 'MM',
M: 'MMM',
n: 'M',
t: '', // no equivalent
L: '', // no equivalent
o: 'YYYY',
Y: 'YYYY',
y: 'YY',
a: 'a',
A: 'A',
B: '', // no equivalent
g: 'h',
G: 'H',
h: 'hh',
H: 'HH',
i: 'mm',
s: 'ss',
u: 'SSS',
e: 'zz', // deprecated since version 1.6.0 of moment.js
I: '', // no equivalent
O: '', // no equivalent
P: '', // no equivalent
T: '', // no equivalent
Z: '', // no equivalent
c: '', // no equivalent
r: '', // no equivalent
U: 'X',
};
let convertedFormat = '';
String(format).split('').forEach(function(char) {
if (Object.keys(replacements).indexOf(char) === -1) {
convertedFormat += char;
} else {
convertedFormat += replacements[char];
}
});
return convertedFormat;
};
this.composeUTCDateFromMacro = function(macroStr) {
const utcFormat = 'YYYY-MM-DDTHH:mm:ssZ';
const dateNow = moment().utc();
// Check if input has the correct format
const dateStr = String(macroStr);
if (dateStr.length === 0 ||
dateStr.match(this.macroRegex) === null
) {
return dateNow.format(utcFormat);
}
// Trim the macro date string
const dateOffsetStr = dateStr.replaceAll('%', '');
const params = (op) => dateOffsetStr.replace(op, '')
.split(/(\d+)/).filter(Boolean);
const addRegex = /^\+/g;
const subtractRegex = /^\-/g;
// Check if it's add or subtract offset and return composed date
if (dateOffsetStr.match(addRegex) !== null) {
return dateNow.add(...params(addRegex)).format(utcFormat);
} else if (dateOffsetStr.match(subtractRegex) !== null) {
return dateNow.subtract(...params(subtractRegex)).format(utcFormat);
}
};
this.formatDate = function(dateStr, format) {
return moment(dateStr).format(format ? format : this.systemFormat);
};
return this;
};
module.exports = new DateFormatHelper();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
module.exports = function(...args) {
return Array.from(args).slice(0, arguments.length - 1);
};

View File

@@ -0,0 +1,3 @@
module.exports = function(array1, array2) {
return array1.concat(array2);
};

View File

@@ -0,0 +1,7 @@
module.exports = function(...args) {
// if last argument is handlebars option, pop it
if (typeof args[args.length - 1] === 'object') {
args.pop();
}
return args.join('');
};

View File

@@ -0,0 +1,7 @@
module.exports = function(v1, v2, opts) {
if (v1 === v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
};

View File

@@ -0,0 +1,19 @@
module.exports = function(
optionArray,
option,
optionsObjectKey,
optionValueKey,
) {
let value;
optionsObjectKey = (typeof optionsObjectKey != 'string') && 'option';
optionValueKey = (typeof optionValueKey != 'string') && 'value';
optionArray.forEach((el) => {
if (option == el[optionsObjectKey]) {
value = el[optionValueKey];
}
});
return value;
};

View File

@@ -0,0 +1,7 @@
module.exports = function(v1, v2, opts) {
if (v1 > v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
};

View File

@@ -0,0 +1,26 @@
module.exports = function(v1, operator, v2, opts) {
switch (operator) {
case '==':
return (v1 == v2) ? opts.fn(this) : opts.inverse(this);
case '===':
return (v1 === v2) ? opts.fn(this) : opts.inverse(this);
case '!=':
return (v1 != v2) ? opts.fn(this) : opts.inverse(this);
case '!==':
return (v1 !== v2) ? opts.fn(this) : opts.inverse(this);
case '<':
return (v1 < v2) ? opts.fn(this) : opts.inverse(this);
case '<=':
return (v1 <= v2) ? opts.fn(this) : opts.inverse(this);
case '>':
return (v1 > v2) ? opts.fn(this) : opts.inverse(this);
case '>=':
return (v1 >= v2) ? opts.fn(this) : opts.inverse(this);
case '&&':
return (v1 && v2) ? opts.fn(this) : opts.inverse(this);
case '||':
return (v1 || v2) ? opts.fn(this) : opts.inverse(this);
default:
return opts.inverse(this);
}
};

View File

@@ -0,0 +1,7 @@
module.exports = function(v1, v2, opts) {
if (v1 !== v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
};

View File

@@ -0,0 +1,3 @@
module.exports = function(str) {
return Number(str);
};

View File

@@ -0,0 +1,3 @@
module.exports = function({hash}) {
return hash;
};

View File

@@ -0,0 +1,8 @@
module.exports = function(v1, v2, opts) {
if (v1 || v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
};

View File

@@ -0,0 +1,7 @@
module.exports = function(partialId, options) {
const selector = 'script[type="text/x-handlebars-template"]#' + partialId;
const source = $(selector).html();
const html = Handlebars.compile(source)(options.hash);
return new Handlebars.SafeString(html);
};

View File

@@ -0,0 +1,7 @@
module.exports = function(str, search, replacement) {
if (typeof str !== 'string') {
return str; // Not a string, return original
}
return str.replace(new RegExp(search, 'g'), replacement);
};

View File

@@ -0,0 +1,3 @@
module.exports = function(varName, varValue, opts) {
opts.data.root[varName] = varValue;
};

View File

@@ -0,0 +1,362 @@
/*
* 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/>.
*/
const PlayerHelper = function() {
// Check the query params to see if we're in editor mode
const self = this;
this.getPinnedSlots = function(dataSlots) {
return Object.keys(dataSlots)
.reduce(function(a, b) {
const dataSlot = dataSlots[b];
if (dataSlot.hasPinnedSlot) return [...a, dataSlot.slot];
return a;
}, []);
};
this.getPinnedItems = function(dataSlotItems) {
if (Object.values(dataSlotItems).length === 0) {
return dataSlotItems;
}
return Object.keys(dataSlotItems).reduce(function(items, itemKey) {
const item = dataSlotItems[itemKey];
if (item.pinSlot) {
items[itemKey] = item;
}
return items;
}, {});
};
/**
* Get items by Key
* @param {Object} items
* @param {String} itemsKey
* @param {Boolean} isStandalone
*
* @return {Array}
*/
this.getItemsByKey = (items, itemsKey, isStandalone) => {
if (isStandalone && items.hasOwnProperty(itemsKey) &&
Object.keys(items[itemsKey]).length > 0
) {
return Object.keys(items[itemsKey]).reduce(function(a, itemKey) {
return [...a, items[itemsKey][itemKey]];
}, []);
}
if (items.hasOwnProperty(itemsKey)) {
return items[itemsKey];
}
return [];
};
/**
* Gets minimum and maximum slot
* If minSlot is zero, it means it's not a data slot
* @param {Array} collection
* @return {{minSlot: (number|number), maxSlot: (number|number)}}
*/
this.getMinAndMaxSlot = function(collection) {
const minValue = 1;
const getSlots = (items) => items.map(function(elem) {
return elem?.slot + 1 || 0;
});
const minSlot = collection === null ?
minValue :
Math.min(...getSlots(collection));
const maxSlot = collection === null ?
minValue :
Math.max(...getSlots(collection));
return {
minSlot,
maxSlot,
};
};
this.getMaxMinSlot = (objectsArray, itemsKey, isStandalone) => {
const minValue = 1;
const groupItems = objectsArray?.length > 0 ?
objectsArray.reduce(
(a, b) => {
return [...a, ...self.getItemsByKey(b, itemsKey, isStandalone)];
}, []) : null;
const getSlots = (items) => items.map(function(elem) {
return elem?.slot || 0;
});
const minSlot = groupItems === null ?
minValue :
Math.min(...getSlots(groupItems)) + 1;
const maxSlot = groupItems === null ?
minValue :
Math.max(...getSlots(groupItems)) + 1;
return {
minSlot,
maxSlot,
};
};
this.isGroup = function(element) {
return element.hasOwnProperty('groupId');
};
this.isMarquee = function(effect) {
return effect === 'marqueeLeft' ||
effect === 'marqueeRight' ||
effect === 'marqueeUp' ||
effect === 'marqueeDown';
};
this.renderElement = function(hbs, props, isStatic) {
const hbsTemplate = hbs(Object.assign(props, globalOptions));
let topPos = props.top;
let leftPos = props.left;
const hasGroup = Boolean(props.groupId);
const hasGroupProps = Boolean(props.groupProperties);
// @NOTE: I think this is deprecated but needs more checking
if (props.group) {
if (props.group.isMarquee) {
topPos = (props.top - props.group.top);
leftPos = (props.left - props.group.left);
} else {
if (props.top >= props.group.top) {
topPos = (props.top - props.group.top);
}
if (props.left >= props.group.left) {
leftPos = (props.left - props.group.left);
}
}
}
let cssStyles = {
height: props.height,
width: props.width,
position: 'absolute',
top: topPos,
left: leftPos,
zIndex: props.layer,
transform: `rotate(${props?.rotation || 0}deg)`,
};
if (isStatic) {
cssStyles = {
...cssStyles,
top: props.top,
left: props.left,
zIndex: props.layer,
};
if (hasGroup && hasGroupProps) {
cssStyles.top = (props.top >= props.groupProperties.top) ?
(props.top - props.groupProperties.top) : 0;
cssStyles.left = (props.left >= props.groupProperties.left) ?
(props.left - props.groupProperties.left) : 0;
cssStyles.zIndex = 'none';
}
}
if (!props.isGroup && props.dataOverride === 'text' &&
(props.group && props.group.isMarquee) &&
(props.effect === 'marqueeLeft' || props.effect === 'marqueeRight')
) {
cssStyles = {
...cssStyles,
position: 'static',
top: 'unset',
left: 'unset',
width: props?.textWrap ? props.width : 'initial',
display: 'flex',
flexShrink: '0',
wordWrap: 'break-word',
};
}
const $renderedElem = $(hbsTemplate).first()
.attr('id', props.elementId)
.addClass(`${props.uniqueID}--item`)
.css(cssStyles);
if (!props.isGroup && props.dataOverride === 'text' &&
(props.group && props.group.isMarquee) &&
(props.effect === 'marqueeLeft' || props.effect === 'marqueeRight')
) {
$renderedElem.get(0).style.removeProperty('white-space');
$renderedElem.get(0).style.setProperty(
'white-space',
props?.textWrap ? 'unset' : 'nowrap',
'important',
);
}
return $renderedElem.prop('outerHTML');
};
this.renderDataItem = function(
isGroup,
dataItemKey,
dataItem,
item,
slot,
maxSlot,
isPinSlot,
pinnedSlots,
groupId,
$groupContent,
groupObj,
meta,
$content,
) {
const $groupContentItem = $(`<div class="${groupId}--item"
data-group-key="${dataItemKey}"></div>`);
const groupKey = '.' + groupId + '--item[data-group-key=%key%]';
// For each data item, parse it and add it to the content;
if (item.hasOwnProperty('hbs') &&
typeof item.hbs === 'function' && dataItemKey !== 'empty'
) {
let groupItemStyles = {
width: groupObj.width,
height: groupObj.height,
};
if (groupObj && groupObj.isMarquee) {
groupItemStyles = {
...groupItemStyles,
position: 'relative',
display: 'flex',
flexShrink: '0',
};
}
$groupContentItem.css(groupItemStyles);
if ($groupContent &&
$groupContent.find(
groupKey.replace('%key%', dataItemKey),
).length === 0
) {
$groupContent.append($groupContentItem);
}
let isSingleElement = false;
if (!isGroup && item.dataOverride === 'text' && groupObj.isMarquee) {
if (item.effect === 'marqueeLeft' || item.effect === 'marqueeRight') {
if ($groupContent.find(
groupKey.replace('%key%', dataItemKey)).length === 1
) {
$groupContent.find(
groupKey.replace('%key%', dataItemKey),
).remove();
}
isSingleElement = true;
} else if (item.effect === 'marqueeDown' ||
item.effect === 'marqueeUp') {
isSingleElement = false;
}
}
const $itemContainer = isSingleElement ?
$groupContent : $groupContent.find(
groupKey.replace('%key%', dataItemKey),
);
const props = Object.assign(
item.templateData,
{isGroup},
(String(item.dataOverride).length > 0 &&
String(item.dataOverrideWith).length > 0) ?
dataItem : {data: dataItem},
{group: groupObj},
);
// Handle special cases where data field name for override
// that's the same as template variable
// E.g. When a dataset column is "text" and the element is using
// text element, extended or not
if (props.isExtended) {
if (props.type === 'dataset' &&
props.hasOwnProperty('datasetField') &&
dataItem.hasOwnProperty(props.datasetField)
) {
props[props.dataOverride] = dataItem[props.datasetField];
} else {
const extendWith =
transformer.getExtendedDataKey(props.dataOverrideWith);
if (props.dataOverride === extendWith &&
dataItem.hasOwnProperty(extendWith)
) {
props[props.dataOverride] = dataItem[extendWith];
}
}
}
const $elementContent = $(self.renderElement(
item.hbs,
props,
));
// Add style scope to container
const $elementContentContainer = $('<div>');
$elementContentContainer.append($elementContent).attr(
'data-style-scope',
'element_' +
props.type + '__' +
props.id,
);
$itemContainer.append(
$elementContentContainer,
);
const itemID = item.uniqueID || item.templateData?.uniqueID;
// Handle the rendering of the template
(item.onTemplateRender() !== undefined) && item.onTemplateRender()(
item.elementId,
$itemContainer.find(`.${itemID}--item`).parent(),
dataItem,
{item, ...item.templateData, data: dataItem},
meta,
);
} else {
if ($groupContent &&
$groupContent.find(
groupKey.replace('%key%', dataItemKey)).length === 0
) {
$groupContent.append($groupContentItem);
}
const $itemContainer = $groupContent.find(
groupKey.replace('%key%', dataItemKey),
);
$itemContainer.append('');
}
};
return this;
};
module.exports = new PlayerHelper();

View File

@@ -0,0 +1,23 @@
/**
* String | Number transformer
*/
const transformer = function() {
return {
getExtendedDataKey: function(value, prefix = 'data.') {
if (typeof value === 'undefined' || String(value).length === 0) {
return null;
}
const dataKeyPrefix = prefix;
const dataKey = String(value);
if (!dataKey.includes(dataKeyPrefix)) {
return dataKey;
}
return dataKey.replaceAll(dataKeyPrefix, '');
},
};
};
module.exports = new transformer();

View File

@@ -0,0 +1,209 @@
/*
* Copyright (C) 2025 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/>.
*/
const confirmationModalTemplate =
require('../templates/confirmation-modal.hbs');
/**
* Action Manager
* @param {object} parent - Parent object
*/
const ActionManager = function(parent) {
this.parent = parent;
this.editing = {};
this.widgetEditing = null;
};
/**
* Add action
* @return {Promise}
*/
ActionManager.prototype.getAllActions = function(layoutId) {
return new Promise((resolve, reject) => {
$.ajax({
url: urlsForApi.actions.get.url,
type: urlsForApi.actions.get.type,
dataType: 'json',
data: {
layoutId: layoutId,
disablePaging: 1,
},
}).done(function(res) {
// Add actions to array
const actions = res.data.reduce((accumulator, action) => {
accumulator[action.actionId] = action;
return accumulator;
}, {});
// Resolve with data
resolve(actions);
}).fail(function(_data) {
toastr.error(
errorMessagesTrans.actionsGetFailed,
errorMessagesTrans.error,
);
reject(errorMessagesTrans.actionsGetFailed);
});
});
};
/**
* Add action
* @param {object} $form - New action form
* @param {string} layoutId - Layout to add action to
* @return {Promise}
*/
ActionManager.prototype.addAction = function($form, layoutId) {
const dataToSave = $($form).serializeObject();
// Add layout Id
dataToSave.layoutId = layoutId;
// If source is types screen, change it to layout
(dataToSave.source === 'screen') &&
(dataToSave.source = 'layout');
return new Promise((resolve, reject) => {
$.ajax({
url: urlsForApi.actions.add.url,
type: urlsForApi.actions.add.type,
data: dataToSave,
}).done(function(_res) {
if (_res.success) {
resolve(_res);
} else {
reject(_res);
}
}).fail((_res) => {
reject(_res);
});
});
};
/**
* Save action
* @param {object} $form - Form to get data from
* @param {string} actionId - Action to save
* @return {Promise}
*/
ActionManager.prototype.saveAction = function($form, actionId) {
const requestURL = urlsForApi.actions.edit.url.replace(
':id',
actionId,
);
return new Promise((resolve, reject) => {
$.ajax({
url: requestURL,
type: urlsForApi.actions.edit.type,
data: $form.serialize(),
}).done(function(_res) {
if (_res.success) {
resolve(_res);
} else {
reject(_res);
}
}).fail(function(_res) {
reject(_res);
});
});
};
/**
* Delete action
* @param {object} action
*/
ActionManager.prototype.deleteAction = function(action) {
const app = this.parent;
// Show confirmation modal
const $modal = $(confirmationModalTemplate(
{
title: editorsTrans.actions.deleteModal.title,
message: editorsTrans.actions.deleteModal.message,
buttons: {
cancel: {
label: editorsTrans.actions.deleteModal.buttons.cancel,
class: 'btn-default cancel',
},
delete: {
label: editorsTrans.actions.deleteModal.buttons.delete,
class: 'btn-danger confirm',
},
},
},
));
const removeModal = function() {
$modal.modal('hide');
// Remove modal
$modal.remove();
// Remove backdrop
$('.modal-backdrop.show').remove();
};
// Add modal to the DOM
app.editorContainer.append($modal);
// Show modal
$modal.modal('show');
return new Promise((resolve, reject) => {
// Confirm button
$modal.find('button.confirm').on('click', function() {
const requestURL = urlsForApi.actions.delete.url.replace(
':id',
action.actionId,
);
$.ajax({
url: requestURL,
type: urlsForApi.actions.delete.type,
}).done(function(_res) {
// Remove modal
removeModal();
// Resolve with true for action deleted
resolve(true);
}).fail(function(_data) {
toastr.error(
errorMessagesTrans.replace('%error%', _data.message),
errorMessagesTrans.error,
);
reject(new Error('%error%', _data.message));
});
});
// Cancel button
$modal.find('button.cancel').on('click', () => {
// Remove modal
removeModal();
// Resolve with false for action not deleted
resolve(false);
});
});
};
module.exports = ActionManager;

View File

@@ -0,0 +1,483 @@
/*
* 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/>.
*/
// CANVAS Module
/**
* Canvas contructor
* @param {number} id - region id
* @param {object} data - data from the API request
* @param {object} layoutDimensions - layout dimensions
*/
const Canvas = function(id, data, layoutDimensions) {
this.id = 'region_' + id;
this.regionId = id;
this.type = 'region';
this.subType = 'canvas';
this.name = data.name;
this.playlists = data.regionPlaylist;
this.loop = false; // Loop region widgets
// Widgets
this.widgets = {};
this.options = data.regionOptions;
// Permissions
this.isEditable = data.isEditable;
this.isDeletable = data.isDeletable;
this.isViewable = data.isViewable;
this.isPermissionsModifiable = data.isPermissionsModifiable;
// Interactive actions
this.actions = data.actions;
// set dimentions
this.dimensions = {
width: layoutDimensions.width,
height: layoutDimensions.height,
top: 0,
left: 0,
};
this.zIndex = data.zIndex;
};
/**
* Change canvas layer
* @param {number} [newLayer] - New left position (for move tranformation)
* @param {bool=} saveToHistory - Flag to save or not to the change history
*/
Canvas.prototype.changeLayer = function(newLayer, saveToHistory = true) {
// add transform change to history manager
if (saveToHistory) {
// save old/previous values
const oldValues = [{
width: this.dimensions.width,
height: this.dimensions.height,
top: this.dimensions.top,
left: this.dimensions.left,
zIndex: this.zIndex,
regionid: this.regionId,
}];
// Update new values if they are provided
const newValues = [{
width: this.dimensions.width,
height: this.dimensions.height,
top: this.dimensions.top,
left: this.dimensions.left,
zIndex: (newLayer != undefined) ?
newLayer : this.zIndex,
regionid: this.regionId,
}];
// Add a tranform change to the history array
lD.historyManager.addChange(
'transform',
'region',
this.regionId,
{
regions: JSON.stringify(oldValues),
},
{
regions: JSON.stringify(newValues),
},
{
upload: true, // options.upload
targetSubType: 'canvas',
},
).catch((error) => {
toastr.error(errorMessagesTrans.transformRegionFailed);
console.error(error);
});
}
// Apply changes to the canvas ( updating values )
this.zIndex = (newLayer != undefined) ?
newLayer : this.zIndex;
};
/**
* Get widgets by type
* @param {string} type - Type of widget
* @param {boolean} getEditableOnly - Get only widgets that can be edited
* @return {object} Found widgets
*/
Canvas.prototype.getWidgetsOfType = function(type, getEditableOnly = true) {
const widgets = {};
const self = this;
Object.values(self.widgets).forEach((widget) => {
if (
(
(
getEditableOnly &&
widget.isEditable
) ||
!getEditableOnly
) &&
widget.subType === type
) {
widgets[widget.widgetId] = widget;
}
});
return widgets;
};
/**
* Get widgets by type
* @param {string} type - Type of widget
* @param {boolean} getEditableOnly - Get only widgets that can be edited
* @param {boolean} getFirstIfNotActive
* - Return first valid widget if we don't have an active
* @return {object} Active or first widget
*/
Canvas.prototype.getActiveWidgetOfType = function(
type,
getEditableOnly = true,
getFirstIfNotActive = true,
) {
const self = this;
let targetWidget = {};
let firstWidgetAdded = false;
Object.values(self.widgets).every((widget) => {
const isValid = (
(
getEditableOnly &&
widget.isEditable
) ||
!getEditableOnly
) &&
widget.subType === type;
// Get first widget or active valid widget
if (isValid) {
if (
!widget.activeTarget &&
!firstWidgetAdded &&
getFirstIfNotActive
) {
// Save targetWidget to be sent
// if we don't find an active one
firstWidgetAdded = true;
targetWidget = widget;
} else if (widget.activeTarget) {
// Active widget, return right away
targetWidget = widget;
return false;
}
}
return true;
});
// Return first found widget if we didn't have any active
return targetWidget;
};
/**
* Move elements between widgets
* @param {object} sourceWidgetId - Old widget id
* @param {object} targetWidgetId - Target widget id
* @param {object[]} elements - Elements to be moved
* @param {object[]} groups - Groups to be moved
*/
Canvas.prototype.moveElementsBetweenWidgets = function(
sourceWidgetId,
targetWidgetId,
elements,
groups,
) {
const self = this;
const sourceWidget = lD.layout.canvas.widgets[sourceWidgetId];
const targetWidget = lD.layout.canvas.widgets[targetWidgetId];
let reloadPropertiesPanel = false;
const updateInViewer = function(
id,
target,
) {
const $target = lD.viewer.DOMObject.find('#' + id);
$target.data('widgetId', target.widgetId);
$target.attr('data-widget-id', target.widgetId);
$target.data('regionId', target.regionId);
$target.attr('data-region-id', target.widgetId);
};
// Move elements
elements.forEach((element) => {
// Change region and widget ids
element.widgetId = targetWidget.widgetId;
element.regionId = targetWidget.regionId.split('_')[1];
if (lD.selectedObject.elementId === element.elementId) {
reloadPropertiesPanel = true;
element.selected = true;
lD.selectedObject = element;
}
// Update in viewer
updateInViewer(element.elementId, element);
lD.viewer.renderElementContent(element);
// Add to new widget
targetWidget.elements[element.elementId] = element;
// Remove from old widget
self.removeFromCanvasWidget(
sourceWidget.id,
element.elementId,
'element',
);
});
// Move groups
groups.forEach((group) => {
const widgetId = targetWidget.widgetId;
const regionId = targetWidget.regionId.split('_')[1];
// Change region and widget ids
group.widgetId = widgetId;
group.regionId = regionId;
if (lD.selectedObject.id === group.id) {
reloadPropertiesPanel = true;
group.selected = true;
lD.selectedObject = group;
}
// Update in viewer
updateInViewer(group.id, group);
Object.values(group.elements).forEach((el) => {
// Change region and widget ids
el.widgetId = widgetId;
el.regionId = regionId;
if (lD.selectedObject.elementId === el.elementId) {
reloadPropertiesPanel = true;
el.selected = true;
lD.selectedObject = el;
}
updateInViewer(el.elementId, el);
lD.viewer.renderElementContent(el);
// Add to new widget
targetWidget.elements[el.elementId] = el;
// Remove from old widget
self.removeFromCanvasWidget(
sourceWidget.id,
el.elementId,
'element',
);
});
// Add to new widget
targetWidget.elementGroups[group.id] = group;
// Remove from old widget
self.removeFromCanvasWidget(
sourceWidget.id,
group.id,
'group',
);
});
// Save both widgets
Promise.all([
sourceWidget.saveElements(),
targetWidget.saveElements(),
]).then((_res) => {
// Reload properties panel
(reloadPropertiesPanel) &&
lD.propertiesPanel.render(lD.selectedObject);
});
};
/**
* Remove elements or group from canvas widget
* @param {object} widgetId - Old widget
* @param {string} objectToRemoveId - Id of the Group or element to be removed
* @param {string} type - group or element
*/
Canvas.prototype.removeFromCanvasWidget = function(
widgetId,
objectToRemoveId,
type,
) {
if (type === 'group') {
delete lD.layout.canvas.widgets[widgetId].elementGroups[objectToRemoveId];
} else {
delete lD.layout.canvas.widgets[widgetId].elements[objectToRemoveId];
}
};
/**
* Edit property by type
* @param {string} property - property to edit
*/
Canvas.prototype.editPropertyForm = function(property) {
const self = this;
const app = lD;
// Load form the API
const linkToAPI = urlsForApi.region['get' + property];
let requestPath = linkToAPI.url;
// Replace widget id
requestPath = requestPath.replace(':id', this.regionId);
// Create dialog
const calculatedId = new Date().getTime();
// Create dialog
const dialog = bootbox.dialog({
className: 'second-dialog',
title: editorsTrans.loadPropertyForObject
.replace('%prop%', property)
.replace('%obj%', 'region'),
message:
'<p><i class="fa fa-spin fa-spinner"></i>' +
editorsTrans.loading +
'...</p>',
size: 'large',
buttons: {
cancel: {
label: translations.cancel,
className: 'btn-white btn-bb-cancel',
},
done: {
label: translations.done,
className: 'btn-primary test btn-bb-done',
callback: function(res) {
app.common.showLoadingScreen();
let dataToSave = '';
const options = {
addToHistory: false, // options.addToHistory
};
// Get data to save
if (property === 'Permissions') {
dataToSave = formHelpers.permissionsFormBeforeSubmit(dialog);
options.customRequestPath = {
url: dialog.find('.permissionsGrid').data('url'),
type: 'POST',
};
} else {
dataToSave = form.serialize();
}
app.historyManager.addChange(
'save' + property,
'widget', // targetType
self.regionId, // targetId
null, // oldValues
dataToSave, // newValues
options,
).then((res) => { // Success
app.common.hideLoadingScreen();
dialog.modal('hide');
app.reloadData(app.layout);
}).catch((error) => { // Fail/error
app.common.hideLoadingScreen();
// Show error returned or custom message to the user
let errorMessage = '';
if (typeof error == 'string') {
errorMessage += error;
} else {
errorMessage += error.errorThrown;
}
// Display message in form
formHelpers.displayErrorMessage(
dialog.find('form'),
errorMessage,
'danger',
);
// Show toast message
toastr.error(errorMessage);
});
},
},
},
}).attr('id', calculatedId).attr('data-test', 'region' + property + 'Form');
// Request and load property form
$.ajax({
url: requestPath,
type: linkToAPI.type,
}).done(function(res) {
if (res.success) {
// Add title
dialog.find('.modal-title').html(res.dialogTitle);
// Add body main content
dialog.find('.bootbox-body').html(res.html);
dialog.data('extra', res.extra);
if (property == 'Permissions') {
formHelpers.permissionsFormAfterOpen(dialog);
}
// Call Xibo Init for this form
XiboInitialise('#' + dialog.attr('id'));
} else {
// Login Form needed?
if (res.login) {
window.location.reload();
} else {
toastr.error(errorMessagesTrans.formLoadFailed);
// Just an error we dont know about
if (res.message == undefined) {
console.error(res);
} else {
console.error(res.message);
}
dialog.modal('hide');
}
}
}).catch(function(jqXHR, textStatus, errorThrown) {
console.error(jqXHR, textStatus, errorThrown);
toastr.error(errorMessagesTrans.formLoadFailed);
dialog.modal('hide');
});
};
module.exports = Canvas;

File diff suppressed because it is too large Load Diff

6021
ui/src/layout-editor/main.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
/*
* 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/>.
*/
// REGION Module
/**
* Region contructor
* @param {number} id - region id
* @param {object} data - data from the API request
* @param {object=} [options] - Region options
* @param {string} [options.backgroundColor="#aaa"] - Color for the background
*/
const Region = function(id, data, {backgroundColor = '#aaa'} = {}) {
this.id = 'region_' + id;
this.regionId = id;
this.type = 'region';
this.subType = data.type;
this.name = data.name;
this.playlists = data.regionPlaylist;
this.isTopLevel = true;
this.backgroundColor = backgroundColor;
this.selected = false;
this.loop = false; // Loop region widgets
this.isEmpty = true; // If the region has widgets or not
// widget structure
this.widgets = {};
this.options = data.regionOptions;
// Permissions
this.isEditable = data.isEditable;
this.isDeletable = data.isDeletable;
this.isViewable = data.isViewable;
this.isPermissionsModifiable = data.isPermissionsModifiable;
this.isPlaylist = data.type === 'playlist';
this.isFrame = data.type === 'frame';
this.isFrameOrZone = (
data.type === 'frame' ||
data.type === 'zone'
);
// Interactive actions
this.actions = data.actions;
// Sync key
this.syncKey = data.syncKey;
// set real dimentions
this.dimensions = {
width: data.width,
height: data.height,
top: data.top,
left: data.left,
};
this.zIndex = data.zIndex;
};
/**
* Transform a region using the new values
* and the layout's scaling and save the values to the structure
* @param {object=} [transform] - Transformation values
* @param {number} [transform.width] - New width (for resize tranformation)
* @param {number} [transform.height] - New height (for resize tranformation)
* @param {number} [transform.top] - New top position (for move tranformation)
* @param {number} [transform.left] - New left position (for move tranformation)
* @param {number} [transform.zIndex]
* - New layer position (for move tranformation)
* @param {bool=} saveToHistory - Flag to save or not to the change history
*/
Region.prototype.transform = function(transform, saveToHistory = true) {
// add transform change to history manager
if (saveToHistory) {
// save old/previous values
const oldValues = [{
width: this.dimensions.width,
height: this.dimensions.height,
top: this.dimensions.top,
left: this.dimensions.left,
zIndex: this.zIndex,
regionid: this.regionId,
}];
// Update new values if they are provided
const newValues = [{
width: (transform.width != undefined) ?
transform.width : this.dimensions.width,
height: (transform.height != undefined) ?
transform.height : this.dimensions.height,
top: (transform.top != undefined) ?
transform.top : this.dimensions.top,
left: (transform.left != undefined) ?
transform.left : this.dimensions.left,
zIndex: (transform.zIndex != undefined) ?
transform.zIndex : this.zIndex,
regionid: this.regionId,
}];
// Add a tranform change to the history array
lD.historyManager.addChange(
'transform',
'region',
this.regionId,
{
regions: JSON.stringify(oldValues),
},
{
regions: JSON.stringify(newValues),
},
{
upload: true, // options.upload
},
).catch((error) => {
toastr.error(errorMessagesTrans.transformRegionFailed);
console.error(error);
});
}
// Apply changes to the region ( updating values )
this.dimensions.width = (transform.width != undefined) ?
transform.width : this.dimensions.width;
this.dimensions.height = (transform.height != undefined) ?
transform.height : this.dimensions.height;
this.dimensions.top = (transform.top != undefined) ?
transform.top : this.dimensions.top;
this.dimensions.left = (transform.left != undefined) ?
transform.left : this.dimensions.left;
this.zIndex = (transform.zIndex != undefined) ?
transform.zIndex : this.zIndex;
};
/**
* Edit property by type
* @param {string} property - property to edit
*/
Region.prototype.editPropertyForm = function(property) {
const self = this;
const app = lD;
// Load form the API
const linkToAPI = urlsForApi.region['get' + property];
let requestPath = linkToAPI.url;
// Replace widget id
requestPath = requestPath.replace(':id', this.regionId);
// Create dialog
const calculatedId = new Date().getTime();
// Create dialog
const dialog = bootbox.dialog({
className: 'second-dialog',
title: editorsTrans.loadPropertyForObject
.replace('%prop%', property)
.replace('%obj%', 'region'),
message:
'<p><i class="fa fa-spin fa-spinner"></i>' +
editorsTrans.loading +
'...</p>',
size: 'large',
buttons: {
cancel: {
label: translations.cancel,
className: 'btn-white btn-bb-cancel',
},
done: {
label: translations.done,
className: 'btn-primary test btn-bb-done',
callback: function(res) {
app.common.showLoadingScreen();
let dataToSave = '';
const options = {
addToHistory: false, // options.addToHistory
};
// Get data to save
if (property === 'Permissions') {
dataToSave = formHelpers.permissionsFormBeforeSubmit(dialog);
options.customRequestPath = {
url: dialog.find('.permissionsGrid').data('url'),
type: 'POST',
};
} else {
dataToSave = form.serialize();
}
app.historyManager.addChange(
'save' + property,
'widget', // targetType
self.regionId, // targetId
null, // oldValues
dataToSave, // newValues
options,
).then((res) => { // Success
app.common.hideLoadingScreen();
dialog.modal('hide');
app.reloadData(app.layout);
}).catch((error) => { // Fail/error
app.common.hideLoadingScreen();
// Show error returned or custom message to the user
let errorMessage = '';
if (typeof error == 'string') {
errorMessage += error;
} else {
errorMessage += error.errorThrown;
}
// Display message in form
formHelpers.displayErrorMessage(
dialog.find('form'),
errorMessage,
'danger',
);
// Show toast message
toastr.error(errorMessage);
});
},
},
},
}).attr('id', calculatedId).attr('data-test', 'region' + property + 'Form');
// Request and load property form
$.ajax({
url: requestPath,
type: linkToAPI.type,
}).done(function(res) {
if (res.success) {
// Add title
dialog.find('.modal-title').html(res.dialogTitle);
// Add body main content
dialog.find('.bootbox-body').html(res.html);
dialog.data('extra', res.extra);
if (property == 'Permissions') {
formHelpers.permissionsFormAfterOpen(dialog);
}
// Call Xibo Init for this form
XiboInitialise('#' + dialog.attr('id'));
} else {
// Login Form needed?
if (res.login) {
window.location.reload();
} else {
toastr.error(errorMessagesTrans.formLoadFailed);
// Just an error we dont know about
if (res.message == undefined) {
console.error(res);
} else {
console.error(res.message);
}
dialog.modal('hide');
}
}
}).catch(function(jqXHR, textStatus, errorThrown) {
console.error(jqXHR, textStatus, errorThrown);
toastr.error(errorMessagesTrans.formLoadFailed);
dialog.modal('hide');
});
};
module.exports = Region;

View File

@@ -0,0 +1,170 @@
/**
* Template Manager
* @param {object} parent - Parent object
*/
const TemplateManager = function(parent) {
this.parent = parent;
this.templates = {};
// Cached requests
this.requests = {};
};
/**
* Get template by id
* @param {string} templateId
* @param {string} templateDataType
* @return {Promise} - Promise with the template object
*/
TemplateManager.prototype.getTemplateById = function(
templateId,
templateDataType,
) {
const self = this;
const clearRequest = function() {
// Clear the request
delete self.requests[templateDataType + '_' + templateId];
};
// If we have the template request, we return it
if (self.requests[templateDataType + '_' + templateId]) {
return self.requests[templateDataType + '_' + templateId];
}
// If we don't have the template, we make the request by dataType
self.requests[
templateDataType + '_' + templateId
] = new Promise((resolve, reject) => {
if (
this.templates[templateDataType] &&
this.templates[templateDataType][templateId]
) {
// Clear the request
clearRequest();
// Return the template
resolve(this.templates[templateDataType][templateId]);
} if (
// If template is global
this.templates['global'] &&
this.templates['global'][templateId]
) {
// Clear the request
clearRequest();
// Return the template
resolve(this.templates['global'][templateId]);
} else {
// If we don't have the template, we make the request by dataType
this.getTemplateByDataType(templateDataType).then((templates) => {
for (const template in templates) {
if (
templates.hasOwnProperty(template) &&
templates[template].templateId === templateId
) {
// Clear the request
clearRequest();
// Return the template
resolve(templates[template]);
}
}
// If we don't find the template, try to find it in global templates
this.getTemplateByDataType('global').then((templates) => {
for (const template in templates) {
if (
templates.hasOwnProperty(template) &&
templates[template].templateId === templateId
) {
// Clear the request
clearRequest();
// Return the template
resolve(templates[template]);
}
}
// Clear the request
clearRequest();
// If we don't find the template, we reject the promise
reject(new Error('Template not found'));
});
});
}
});
// Return the request
return self.requests[templateDataType + '_' + templateId];
};
/**
* Get templated by dataType
* @param {string} templateDataType
* @return {Promise} - Promise with the template objects
*/
TemplateManager.prototype.getTemplateByDataType = function(
templateDataType,
) {
const self = this;
const clearRequest = function() {
// Clear the request
delete self.requests[templateDataType];
};
// If we already have a request for this dataType, we return it
if (self.requests[templateDataType]) {
return self.requests[templateDataType];
}
// If we don't have the templates, we make the request
self.requests[
templateDataType
] = new Promise((resolve, reject) => {
if (this.templates[templateDataType]) {
// Clear the request
clearRequest();
// Return the templates
resolve(this.templates[templateDataType]);
} else {
// Make the request to get all templates
let requestPath = urlsForApi.module.getTemplates.url;
requestPath = requestPath.replace(':dataType', templateDataType);
$.ajax({
url: requestPath,
type: urlsForApi.module.getTemplates.type,
}).done(function(res) {
if (res.data) {
// Save the templates in the manager
self.templates[templateDataType] = {};
for (let i = 0; i < res.data.length; i++) {
self.templates[templateDataType][res.data[i].templateId] =
res.data[i];
}
// Clear the request
clearRequest();
// Return the templates
resolve(self.templates[templateDataType]);
} else {
// Clear the request
clearRequest();
// Reject the promise
reject(res);
}
});
}
});
// Return the request
return self.requests[templateDataType];
};
module.exports = TemplateManager;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,455 @@
let table;
// Configure the DataTable
$(document).ready(function() {
if (!folderViewEnabled) {
disableFolders();
}
table = $('#campaigns').DataTable({
language: dataTablesLanguage,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
dom: dataTablesTemplate,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
order: [[0, 'asc']],
ajax: {
url: campaignSearchURL,
data: function(d) {
$.extend(
d,
$('#campaigns').closest('.XiboGrid')
.find('.FilterDiv form').serializeObject(),
);
},
},
columns: [
{
data: 'campaign',
responsivePriority: 2,
render: dataTableSpacingPreformatted,
},
// Add fields only if campaign is enabled
...(adCampaignEnabled ? [
{
data: 'type',
responsivePriority: 2,
render: function(data, type) {
if (type !== 'display') {
return data;
} else if (data === 'list') {
return campaignPageTrans.list;
} else if (data === 'ad') {
return campaignPageTrans.ad;
}
return data;
},
},
{
data: 'startDt',
responsivePriority: 2,
render: dataTableDateFromUnix,
},
{
data: 'endDt',
responsivePriority: 2,
render: dataTableDateFromUnix,
},
] : []),
{data: 'numberLayouts', responsivePriority: 2},
// Add tags only if enabled
...(taggingEnabled ? [{
sortable: false,
responsivePriority: 2,
data: dataTableCreateTags,
}] : []),
{
data: 'totalDuration',
responsivePriority: 2,
render: dataTableTimeFromSeconds,
},
{
name: 'cyclePlaybackEnabled',
responsivePriority: 3,
data: function(data, type) {
if (type != 'display') {
return data.cyclePlaybackEnabled;
}
let icon = '';
if (data.cyclePlaybackEnabled == 1) {
icon = 'fa-check';
} else {
icon = 'fa-times';
}
return '<span class="fa ' + icon + '"></span>';
},
},
{
name: 'playCount',
responsivePriority: 3,
data: function(data, type) {
if (type !== 'display') {
return data.playCount;
}
if (!data.playCount) {
return '';
} else {
return data.playCount;
}
},
},
// Add fields only if campaign is enabled
...(adCampaignEnabled ? [
{
data: 'targetType',
responsivePriority: 3,
render: function(data, type) {
if (data === 'plays') {
return campaignPageTrans.plays;
} else if (data === 'budget') {
return campaignPageTrans.budget;
} else if (data === 'imp') {
return campaignPageTrans.impressions;
}
return data;
},
},
{
data: 'target',
responsivePriority: 3,
},
{
data: 'plays',
responsivePriority: 6,
},
{
data: 'spend',
responsivePriority: 6,
},
{
data: 'impressions',
responsivePriority: 6,
},
] : []),
{
data: 'ref1',
responsivePriority: 10,
visible: false,
},
{
data: 'ref2',
responsivePriority: 10,
visible: false,
},
{
data: 'ref3',
responsivePriority: 10,
visible: false,
},
{
data: 'ref4',
responsivePriority: 10,
visible: false,
},
{
data: 'ref5',
responsivePriority: 10,
visible: false,
},
{
data: 'createdAt',
responsivePriority: 5,
render: dataTableDateFromIso,
visible: false,
},
{
data: 'modifiedAt',
responsivePriority: 5,
render: dataTableDateFromIso,
visible: false,
},
{
data: 'modifiedByName',
responsivePriority: 5,
visible: false,
},
{
orderable: false,
responsivePriority: 1,
data: dataTableButtonsColumn,
},
],
});
// Data Table events
table.on('draw', dataTableDraw);
table.on('draw',
{
form: $('#campaigns').closest('.XiboGrid')
.find('.FilterDiv form'),
}, dataTableCreateTagEvents);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(
table,
$('#campaigns_wrapper').find('.dataTables_buttons'),
);
$('#refreshGrid').click(function() {
table.ajax.reload();
});
});
// Callback for the media form
// Fired when the media form opens
window.campaignAssignLayoutsFormOpen = function(dialog) {
// setup checkbox behaviour for cycle based playback
formHelpers.setupCheckboxInputFields(
$(dialog).find('form:not(.form-inline)'),
'input[name="cyclePlaybackEnabled"]',
'.cycle-based-playback',
'.no-cycle-based-playback',
);
// Layout element template
const layoutElementTemplate = templates.campaign.campaignAssignLayout;
const layoutAssignFilter = $(dialog).find('.layoutAssignFilterOptions');
// Change input id of the tags filter on Layout assignment tab.
layoutAssignFilter.find('input#tags').attr('id', 'tagsFilter');
// Assignment table
const $layoutAssignments = $('#layoutAssignments');
const $layoutAssignSortable = $('#LayoutAssignSortable');
// Update all the layout element positions
const updateSortablePositions = function() {
dialog.find('input[name="manageLayouts"]').val(1);
$layoutAssignSortable.find('li').each(function(idx, el) {
$(el).find('.layout-order').html(idx + 1);
});
};
// Populate layouts
const layoutsArray = $layoutAssignSortable.data('layouts');
for (layoutIndex = 0; layoutIndex < layoutsArray.length; layoutIndex++) {
const layout = layoutsArray[layoutIndex];
// Append to our layouts list
const newItem = layoutElementTemplate({
index: (layoutIndex + 1),
layoutId: layout.layoutId,
layoutName: layout.layout,
locked: layout.locked,
});
$(newItem).appendTo('#LayoutAssignSortable');
}
// Layout DataTable
const layoutTable = $layoutAssignments.DataTable({
language: dataTablesLanguage,
serverSide: true,
stateSave: true,
stateDuration: 0,
pageLength: 5,
lengthMenu: [5, 10, 25, 50],
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
searchDelay: 3000,
order: [[0, 'asc']],
filter: false,
ajax: {
url: layoutSearchURL + '?retired=0',
data: function(d) {
$.extend(d, $layoutAssignments.closest('.XiboGrid')
.find('.layoutAssignFilterOptions')
.find('input, select')
.serializeObject());
},
},
columns: [
{data: 'layoutId'},
{
data: 'layout',
render: dataTableSpacingPreformatted,
},
{
name: 'status',
data: function(data, type) {
if (type != 'display') {
return data.status;
}
let icon = '';
if (data.status == 1) {
icon = 'fa-check';
} else if (data.status == 2) {
icon = 'fa-exclamation';
} else if (data.status == 3) {
icon = 'fa-cogs';
} else {
icon = 'fa-times';
}
return '<span class=\'fa ' + icon +
'\' title=\'' + (data.statusDescription) +
((data.statusMessage == null) ? '' : ' - ' + (data.statusMessage)) +
'\'></span>';
},
},
{
sortable: false,
data: function(data, type, row, meta) {
if (type !== 'display') {
return '';
}
// Create a click-able span
return '<a href="#" class="assignItem"><span class="fa fa-plus"></a>';
},
},
],
});
layoutTable.on(
'draw',
{
form: $layoutAssignments.closest('.XiboGrid').find('form'),
}, function(e, settings) {
dataTableDraw(e, settings);
dataTableCreateTagEvents(e, settings);
// Bind a click event to each table rows + button (span)
$layoutAssignments.find('.assignItem').on('click', function(ev) {
// Get the row that this is in.
const data = layoutTable.row($(ev.currentTarget).closest('tr')).data();
// Append to our layouts list
const newItem = layoutElementTemplate({
index: ($('#LayoutAssignSortable').find('li').length + 1),
layoutId: data.layoutId,
layoutName: data.layout,
locked: false,
});
$(newItem).appendTo('#LayoutAssignSortable');
dialog.find('input[name="manageLayouts"]').val(1);
});
});
layoutTable.on('processing.dt', dataTableProcessing);
// Make our little list sortable
$layoutAssignSortable.sortable({
cancel: '.ui-state-disabled',
update: function(event, ui) {
updateSortablePositions();
},
});
// Bind to the existing items in the list
$layoutAssignSortable.on('click', '.layout-remove', function(ev) {
$(ev.currentTarget).parent().remove();
updateSortablePositions();
});
// Bind the filter form
layoutAssignFilter.find('input, select').change(function() {
layoutTable.ajax.reload();
});
// Adjust the datatable width once we've activated the tab
$(dialog).find('.nav-tabs a').on('shown.bs.tab', function(event) {
if ($(event.target).attr('href') === '#tab-layouts') {
layoutAssignFilter.find('input, select').prop('disabled', false);
layoutTable.columns.adjust().draw();
}
});
};
window.campaignFormSubmit = function($form) {
// Process layouts to add
layoutAssignSubmit($form);
// disable inputs from layout assignment filter
// we do not want to submit them.
$('.layoutAssignFilterOptions').find('input, select').prop('disabled', true);
// Submit form
$form.submit();
};
function layoutAssignSubmit($form) {
if (parseInt($form.find('input[name="manageLayouts"]').val()) === 1) {
// Get the final sortable positions
const finalLayoutPositions = [];
$('#LayoutAssignSortable').find('li').each(function(key, el) {
finalLayoutPositions.push($(el).data('layoutId'));
});
// Build the array of layouts
for (let i = 0; i < finalLayoutPositions.length; i++) {
$('<input>').attr({
type: 'hidden',
name: 'layoutIds[' + i + ']',
}).val(finalLayoutPositions[i]).appendTo($form.find('#assignLayouts'));
}
}
}
/**
* Called when the campaign add form is opened
* @param dialog
*/
window.campaignAddFormOpen = function(dialog) {
// setup checkbox behaviour for cycle based playback
formHelpers.setupCheckboxInputFields(
$(dialog).find('form'),
'input[name="cyclePlaybackEnabled"]',
'.cycle-based-playback',
'.no-cycle-based-playback',
);
const $type = $(dialog).find('select[name=type]');
const $cycleBased = $('input[name="cyclePlaybackEnabled"]');
$(dialog).find('.campaign-type-ad').toggle($type.val() === 'ad');
$type.on('change', function() {
$(dialog).find('.campaign-type-list').toggle($type.val() !== 'ad');
$(dialog).find('.campaign-type-ad').toggle($type.val() === 'ad');
$(dialog).find('.cycle-based-playback')
.toggle($cycleBased.is(':checked') && $type.val() !== 'ad');
$(dialog).find('.no-cycle-based-playback')
.toggle(!$cycleBased.is(':checked') && $type.val() !== 'ad');
});
};
/**
* Called when the campaign add form is submitted.
* @param xhr
* @param form
*/
window.campaignAddFormSubmitCallback = function(xhr, form) {
if (xhr.success) {
if (xhr.data.type === 'ad') {
// Navigate to the campaign builder
} else {
// Open the edit form.
XiboFormRender(
$(form).data('editFormUrl').replace(':id', xhr.data.campaignId),
);
}
}
};

View File

@@ -0,0 +1,123 @@
const table = $('#templates').DataTable({
language: dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
order: [[0, 'asc']],
ajax: {
url: developerTemplatesSearchURL,
data: function(d) {
$.extend(d, $('#templates').closest('.XiboGrid')
.find('.FilterDiv form').serializeObject());
},
},
columns: [
{data: 'id', responsivePriority: 2},
{data: 'templateId', responsivePriority: 2},
{data: 'dataType'},
{data: 'title', orderable: false},
{data: 'type', orderable: false},
{
data: 'groupsWithPermissions',
responsivePriority: 3,
render: dataTableCreatePermissions,
},
{
orderable: false,
responsivePriority: 1,
data: dataTableButtonsColumn,
},
],
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#templates_wrapper').find('.dataTables_buttons'));
$('#refreshGrid').on('click', function() {
table.ajax.reload();
});
$('#module-template-xml-import').on('click', function(e) {
e.preventDefault();
openUploadForm({
url: developerTemplatesImportURL,
templateId: 'template-module-xml-upload',
uploadTemplateId: 'template-module-xml-upload-files',
title: developerTemplatePageTrans.importXML,
initialisedBy: 'module-templates-upload',
buttons: {
main: {
label: developerTemplatePageTrans.done,
className: 'btn-primary btn-bb-main',
callback: function() {
table.ajax.reload();
XiboDialogClose();
},
},
},
templateOptions: {
multi: false,
trans: developerTemplatePageTrans.templateOptions,
upload: {
validExt: 'xml',
},
},
});
});
window.moduleTemplateAddFormOpen = function(dialog) {
const $form = $(dialog).find('#form-module-template');
$('#dataType', $form)
.on('select2:select', function(e) {
const dataType = $(e.currentTarget).select2('data')[0].id;
const $templateSelect = $(dialog).find('#copyTemplateId');
const searchUrl =
moduleTemplateSearchURL.replace(':dataType', dataType);
$templateSelect.data('searchUrl', searchUrl);
makeTemplateSelect($templateSelect);
$templateSelect.parent().parent().removeClass('d-none');
$templateSelect.val(null).trigger('change');
});
};
function makeTemplateSelect($element) {
// clear existing options.
$element.empty().trigger('change');
// append empty option
$element.append(new Option('', '', false, false));
// get static templates for the selected dataType
$.ajax({
method: 'GET',
url: $element.data('searchUrl'),
data: $element.data('filterOptions'),
dataType: 'json',
success: function(response) {
$.each(response.data, function(key, el) {
$element.append(new Option(el.title, el.templateId, false, false));
});
$element.select2({
allowClear: true,
placeholder: {
id: null,
value: '',
},
});
},
error: function(xhr) {
SystemMessage(
xhr.message || developerTemplatePageTrans.unknownError,
false);
},
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
#settings-from-profile tr.row-fluid {
height: 50px;
}

View File

@@ -0,0 +1,536 @@
$(function() {
// Select lists
const dialog = 'body';
window.scheduleEvents = [];
const $campaignSelect = $('#schedule-filter #campaignIdFilter');
$campaignSelect.select2({
dropdownParent: $(dialog),
ajax: {
url: $campaignSelect.data('searchUrl'),
dataType: 'json',
delay: 250,
placeholder: 'This is my placeholder',
allowClear: true,
data: function(params) {
const query = {
isLayoutSpecific: -1,
retired: 0,
totalDuration: 0,
name: params.term,
start: 0,
length: 10,
excludeMedia: 1,
columns: [
{
data: 'isLayoutSpecific',
},
{
data: 'campaign',
},
],
order: [
{
column: 0,
dir: 'asc',
},
{
column: 1,
dir: 'asc',
},
],
};
// Set the start parameter based on the page number
if (params.page != null) {
query.start = (params.page - 1) * 10;
}
return query;
},
processResults: function(data, params) {
const results = [];
const campaigns = [];
const layouts = [];
$.each(data.data, function(index, element) {
if (element.isLayoutSpecific === 1) {
layouts.push({
id: element.campaignId,
text: element.campaign,
});
} else {
campaigns.push({
id: element.campaignId,
text: element.campaign,
});
}
});
if (campaigns.length > 0) {
results.push({
text: $campaignSelect.data('transCampaigns'),
children: campaigns,
});
}
if (layouts.length > 0) {
results.push({
text: $campaignSelect.data('transLayouts'),
children: layouts,
});
}
let page = params.page || 1;
page = (page > 1) ? page - 1 : page;
return {
results: results,
pagination: {
more: (page * 10 < data.recordsTotal),
},
};
},
},
}).on('select2:open', function(event) {
setTimeout(function() {
$(event.target).data('select2').dropdown.$search.get(0).focus();
}, 10);
});
const table = $('#schedule-grid').DataTable({
language: dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: false,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
order: [],
ajax: {
url: scheduleSearchUrl,
data: function(d) {
const filterData = $('#schedule-grid').closest('.XiboGrid')
.find('.FilterDiv form').serializeObject();
// Disable paging on the back-end
d.disablePaging = 1;
$.extend(d, filterData);
},
dataSrc(json) {
scheduleEvents = json.data;
return json.data;
},
},
columns: [
{
data: 'eventId',
responsivePriority: 5,
className: 'none',
},
{
name: 'icon',
className: 'align-middle',
responsivePriority: 2,
data: function(data) {
let eventIcon = 'fa-desktop';
let eventClass = 'event-warning';
if (data.displayGroups.length <= 1) {
eventClass = 'event-info';
} else {
eventClass = 'event-success';
}
if (data.isAlways == 1) {
eventIcon = 'fa-retweet';
}
if (data.recurrenceType != null && data.recurrenceType != '') {
eventClass = 'event-special';
eventIcon = 'fa-repeat';
}
if (data.isPriority >= 1) {
eventClass = 'event-important';
eventIcon = 'fa-bullseye';
}
if (data.eventTypeId == 2) {
eventIcon = 'fa-wrench';
}
if (data.eventTypeId == 4) {
eventIcon = 'fa-hand-paper';
}
if (data.isGeoAware === 1) {
eventIcon = 'fa-map-marker';
}
if (data.eventTypeId == 6) {
eventIcon = 'fa-paper-plane';
}
if (data.eventTypeId == 9) {
eventIcon = 'fa-refresh';
}
if (!data.isEditable) {
eventIcon = 'fa-lock';
eventClass = 'event-inverse';
}
return '<span class="fa ' + eventIcon + ' ' +
eventClass + ' "></span>';
},
},
{
name: 'eventTypeId',
className: 'align-middle',
responsivePriority: 2,
data: function(data) {
return data.eventTypeName;
},
},
{
data: 'name',
className: 'align-middle',
responsivePriority: 3,
},
{
name: 'fromDt',
className: 'align-middle',
responsivePriority: 2,
data: function(data) {
if (data.isAlways === 1) {
return schedulePageTrans.always;
} else {
return moment(data.displayFromDt, systemDateFormat)
.format(jsDateFormat);
}
},
},
{
name: 'toDt',
className: 'align-middle',
responsivePriority: 2,
data: function(data) {
if (data.isAlways === 1) {
return schedulePageTrans.always;
} else {
return moment(data.displayToDt, systemDateFormat)
.format(jsDateFormat);
}
},
},
{
name: 'campaign',
className: 'align-middle',
responsivePriority: 2,
data: function(data) {
if (data.eventTypeId === 9) {
return data.syncType;
} else if (data.eventTypeId === 2) {
return data.command;
} else {
return data.campaign;
}
},
},
{
data: 'campaignId',
responsivePriority: 5,
className: 'none',
},
{
name: 'displayGroups',
className: 'align-middle',
responsivePriority: 2,
sortable: false,
data: function(data) {
if (data.displayGroups.length > 1 && data.eventTypeId !== 9) {
return '<span class="badge" ' +
'style="background-color: green; color: white" ' +
'data-toggle="popover" data-trigger="click" ' +
'data-placement="top" data-content="' +
data.displayGroupList + '">' + (data.displayGroups.length) +
'</span>';
} else {
return data.displayGroupList;
}
},
},
{
data: 'shareOfVoice',
className: 'align-middle',
responsivePriority: 4,
},
{
name: 'maxPlaysPerHour',
className: 'align-middle',
responsivePriority: 4,
data: function(data) {
if (data.maxPlaysPerHour === 0) {
return translations.unlimited;
} else {
return data.maxPlaysPerHour;
}
},
},
{
data: 'isGeoAware',
className: 'align-middle',
responsivePriority: 4,
render: dataTableTickCrossColumn,
},
{
data: 'recurringEvent',
className: 'align-middle',
responsivePriority: 4,
render: dataTableTickCrossColumn,
},
{
data: 'recurringEventDescription',
className: 'align-middle',
responsivePriority: 4,
sortable: false,
},
{
data: 'recurrenceType',
className: 'align-middle',
visible: false,
responsivePriority: 4,
},
{
data: 'recurrenceDetail',
className: 'align-middle',
visible: false,
responsivePriority: 4,
},
{
name: 'recurrenceRepeatsOn',
className: 'align-middle',
visible: false,
responsivePriority: 4,
data: function(data) {
if (data.recurringEvent) {
if (data.recurrenceType === 'Week' && data.recurrenceRepeatsOn) {
const daysOfTheWeek = [
schedulePageTrans.daysOfTheWeek.monday,
schedulePageTrans.daysOfTheWeek.tuesday,
schedulePageTrans.daysOfTheWeek.wednesday,
schedulePageTrans.daysOfTheWeek.thursday,
schedulePageTrans.daysOfTheWeek.friday,
schedulePageTrans.daysOfTheWeek.saturday,
schedulePageTrans.daysOfTheWeek.sunday,
];
const recurrenceArray = data.recurrenceRepeatsOn.split(',');
if (recurrenceArray.length >= 1) {
let stringToReturn = '';
// go through each selected day, get the corresponding day name
recurrenceArray.forEach((dayNumber, index) => {
stringToReturn += daysOfTheWeek[dayNumber - 1];
if (index < recurrenceArray.length - 1) {
stringToReturn += ' ';
}
});
return stringToReturn;
} else {
return '';
}
} else if (data.recurrenceType === 'Month') {
return data.recurrenceMonthlyRepeatsOn;
} else {
return '';
}
} else {
return '';
}
},
},
{
name: 'recurrenceRange',
className: 'align-middle',
visible: false,
responsivePriority: 4,
data: function(data) {
if (data.recurringEvent && data.recurrenceRange !== null) {
return moment(data.recurrenceRange, 'X').format(jsDateFormat);
} else {
return '';
}
},
},
{
data: 'isPriority',
className: 'align-middle',
responsivePriority: 2,
},
{
name: 'criteria',
className: 'align-middle',
responsivePriority: 2,
data: function(data, type, row) {
return (data.criteria && data.criteria.length > 0) ?
dataTableTickCrossColumn(1, type, row) : '';
},
},
{
data: 'createdOn',
className: 'align-middle',
responsivePriority: 4,
},
{
data: 'updatedOn',
className: 'align-middle',
responsivePriority: 4,
},
{
data: 'modifiedByName',
className: 'align-middle',
responsivePriority: 4,
},
{
orderable: false,
className: 'align-middle',
responsivePriority: 1,
data: dataTableButtonsColumn,
},
],
});
table.on('draw', function(e, settings) {
dataTableDraw(e, settings);
$('[data-toggle="popover"]').popover();
});
table.on('processing.dt', function(e, settings, processing) {
if (processing) {
$('#calendar-progress').addClass('show');
} else {
$('#calendar-progress').removeClass('show');
// Reload calendar view
calendar.view();
}
dataTableProcessing(e, settings, processing);
});
dataTableAddButtons(
table,
$('#schedule-grid_wrapper').find('.dataTables_buttons'),
true,
true,
);
function changeCalendarView(calendarView = null) {
// If we are in calendar view, and using custom dates
// select month in the Range
if (
$('.XiboSchedule .card-header-tabs .nav-item .nav-link.active')
.data().scheduleView === 'calendar' &&
$('#schedule-filter #range').val() != 'month'
) {
$('#schedule-filter #range').val('month').trigger('change');
// Stop here, trigger above will call this method again
return;
}
if (calendarView && calendarView != calendar.options.view) {
// Reload calendar with tab view
calendar.view(calendarView);
} else if (
!calendarView &&
$('#schedule-filter #range').val() != 'custom'
) {
// Reload calendar with range value as view
calendar.view($('#schedule-filter #range').val());
}
}
function changeRangeVisibility(show = true) {
$('#schedule-filter .date-range-input').toggle(show);
}
// Save View tab preference
$('.XiboSchedule .card-header-tabs .nav-item .nav-link')
.on('shown.bs.tab', function(ev) {
const tabData = $(ev.currentTarget).data();
changeCalendarView(tabData.calendarView);
changeRangeVisibility(tabData.scheduleView === 'grid');
$.ajax({
type: 'post',
url: userPreferencesUrl,
cache: false,
dataType: 'json',
data: {
preference: [{
option: 'schedulePageView',
value: $(ev.currentTarget).attr('id'),
}],
},
});
});
// On range change, change calendar view
$('#schedule-filter #range').on('change', (_ev) => {
changeCalendarView();
});
changeCalendarView();
// Select tab on page load
$.ajax({
type: 'GET',
async: false,
url: userPreferencesUrl + '?preference=schedulePageView',
dataType: 'json',
success: function(json) {
try {
if (json.success) {
// Open tab
$('.XiboSchedule .card-header-tabs #' + json.data.value)
.trigger('click');
}
} catch (e) {
// Do nothing
console.warn(e);
}
},
});
// Set up the navigational controls
$('.btn-group button[data-calendar-nav]').on('click', function(ev) {
const $el = $(ev.currentTarget);
updateRangeFilter($('#range'), $('#fromDt'), $('#toDt'), () => {
calendar.navigate($el.data('calendar-nav'));
}, {direction: $el.data('calendar-nav')});
});
// Refresh grid button
$('#refreshGrid').on('click', function() {
table.ajax.reload();
});
// When closing a modal on this page, reload table
// (to reflect possible changes)
// except for the agenda view modal
$(document).on('hidden.bs.modal', '.modal', function(e) {
if (
$(e.target).hasClass('bootbox') &&
!$(e.target).hasClass('agenda-view-modal')
) {
table.ajax.reload();
}
});
});

View File

@@ -0,0 +1,169 @@
import './welcome-page.scss';
function showVideoModal(videoLinks) {
const multipleVideo = (videoLinks.length > 1);
const showVideo = function($modal, index) {
const $videoModalContent = $(templates.welcome.videoModalContent({
videoIndex: index,
videoLink: videoLinks[index].link,
showControls: multipleVideo,
numVideoLinks: videoLinks.length,
videoThumbnails: videoLinks.map((_el, idx) => {
_el.selected = (idx == index);
return _el;
}),
buttonDisabled: {
previous: (index === 0),
next: (index === (videoLinks.length - 1)),
},
}));
// Append to modal body
$modal.find('.welcome-video-body').html($videoModalContent);
// Handle controls
if (multipleVideo) {
$videoModalContent.find('.welcome-video-thumb:not(.checked)').on(
'click',
function(ev) {
const $btn = $(ev.currentTarget);
showVideo($modal, $btn.data('idx'));
});
}
};
const removeModal = function($modal) {
$modal.modal('hide');
// Remove modal
$modal.remove();
// Remove backdrop
$('.modal-backdrop.show').remove();
};
// Create modal
const $videoModal = $(templates.welcome.videoModal());
// Add modal to the DOM
$('body').append($videoModal);
// Show first video
showVideo($videoModal, 0);
// Show modal
$videoModal.modal('show');
// Close button
$videoModal.find('button.modal-close').on(
'click',
function() {
removeModal($videoModal);
});
}
$(function() {
// Onboarding cards
for (let index = 0; index < onboardingCard.length; index++) {
const card = onboardingCard[index];
const $newCard = $(templates.welcome.welcomeCard(card));
$newCard.on('click', function(e) {
e.preventDefault();
const targetId = $(e.currentTarget).attr('href');
const $targetElement = $(targetId);
if ($targetElement.length) {
const offset = $targetElement.offset().top - 100;
$('html, body').animate({
scrollTop: offset,
}, 800);
$targetElement.css({
border: '3px solid #0e70f6',
transition: 'border-color 1s ease-out',
});
$targetElement.addClass('highlighted');
setTimeout(function() {
$targetElement.css('border-color', 'transparent');
$targetElement.removeClass('highlighted');
}, 1000);
}
});
$newCard.appendTo('.welcome-page .onboarding-cards-container');
}
// Service cards
for (let index = 0; index < serviceCards.length; index++) {
const card = serviceCards[index];
let targetContainer = null;
if (card.featureFlag === 'displays.view') {
targetContainer = '.service-card-container .displays-enabled';
} else if (
Array.isArray(card.featureFlag) &&
card.featureFlag.includes('library.view') ||
card.featureFlag.includes('layout.view')
) {
targetContainer = '.service-card-container .library-layout-enabled';
} else if (card.featureFlag === 'schedule.view') {
targetContainer = '.service-card-container .schedule-enabled';
}
if (targetContainer && $(targetContainer).length) {
const $serviceCard = $(templates.welcome.serviceCard(card));
$serviceCard.appendTo(targetContainer);
// Card video link
// don't show for white label
if (card.videoLinks && isXiboThemed) {
const $videoOverlay =
$serviceCard.find('.service-card-image-video-overlay');
const videoLinks = Array.isArray(card.videoLinks) ?
card.videoLinks : [card.videoLinks];
// Only add if we have links
if (videoLinks.length > 0) {
// Show and handle overlay click
$videoOverlay.addClass('active').on('click', function(e) {
console.log('Open video: ' + videoLinks.length);
console.log(videoLinks);
showVideoModal(videoLinks);
});
}
}
}
}
// Other cards
const $otherCardContainer = $('.welcome-page .others-card-container');
$otherCardContainer.toggleClass('multi-card', (othersCards.length > 1));
for (let index = 0; index < othersCards.length; index++) {
const card = othersCards[index];
const $othersCard = $(templates.welcome.othersCard(card));
$othersCard.appendTo($otherCardContainer);
}
// Scroll up button
const scrollUpButton = $('.scroll-up');
$(window).on('scroll', function() {
if ($(window).scrollTop() > 200) {
scrollUpButton.fadeIn();
} else {
scrollUpButton.fadeOut();
}
});
scrollUpButton.on('click', function(e) {
e.preventDefault();
$('html, body').animate({scrollTop: 0}, 'smooth');
});
});

View File

@@ -0,0 +1,518 @@
@mixin box-shadow($shadow: 0px 5px 30px 0px rgba(0, 0, 0, 0.1)) {
box-shadow: $shadow;
}
.welcome-page {
font-family: 'Open Sans Regular';
display: flex;
flex-direction: column;
background-color: var(--welcome-color-white);
background-size: cover;
background-position: top;
background-repeat: no-repeat;
padding: 80px 80px;
overflow: hidden;
position: relative;
top: -15px;
left: -0;
margin-right: -15px;
margin-bottom: -40px;
min-height: 100vh;
}
.welcome-header {
display: flex;
width: 100%;
align-items: center;
h2 {
font-size: 40px;
color: var(--welcome-color-primary);
margin-bottom: 16px;
}
p {
font-size: 20px;
}
.header-text-content {
display: flex;
flex-direction: column;
width: 50%;
padding-right: 120px;
}
.header-image-box {
display: flex;
width: 50%;
}
}
@media (max-width: 1400px) {
.welcome-page {
padding: 60px 40px;
}
.welcome-header {
flex-direction: column;
row-gap: 40px;
.header-text-content {
width: 100%;
padding-right: 0;
}
.header-image-box {
width: 100%;
}
}
.onboarding-cards-container {
margin-top: 40px !important;
}
.service-card-container {
margin-top: 40px !important;
}
}
@media (max-width: 1290px) {
.others-card-container {
grid-template-columns: 1fr !important;
}
}
@media (max-width: 1215px) {
.welcome-page {
padding: 35px 30px;
}
.service-card {
flex-direction: column;
flex-wrap: wrap;
.service-card-text {
margin-top: 20px;
}
}
}
@media (max-width: 995px) {
.onboarding-cards-container {
display: grid !important;
grid-template-columns: 1fr 1fr;
.onboarding-card {
width: 100%;
}
}
.line-curve {
bottom: -15% !important;
}
.welcome-page {
right: -4px;
}
}
.btn-rounded {
border-radius: 100px;
padding: 7px 20px;
font-size: 16px;
font-weight: bold;
transition: background-color 0.3s ease;
text-decoration: none !important;
}
.btn-secondary {
background-color: var(--welcome-color-secondary);
color: var(--welcome-color-white);
border: none;
&:hover {
background-color: var(--welcome-color-secondary-dark);
color: var(--welcome-color-white);
}
}
.btn-outlined {
background-color: var(--welcome-color-white);
color: var(--welcome-color-primary);
border: 2px solid var(--welcome-color-primary);
&:hover {
background-color: var(--welcome-color-primary);
color: var(--welcome-color-white);
}
}
.onboarding-cards-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 80px;
position: relative;
min-height: 160px;
}
.onboarding-card {
@include box-shadow;
display: flex;
flex-direction: column;
border-radius: 8px;
height: 160px;
width: 234px;
padding: 20px;
justify-content: space-between;
color: var(--welcome-color-primary);
transition: background-color 0.3s ease;
background-color: var(--welcome-color-white);
z-index: 2;
cursor: pointer;
text-decoration: none;
h3 {
font-size: 20px;
line-height: 16px;
margin-bottom: 0;
font-weight: 600;
text-decoration: none;
}
&:hover {
background-color: var(--welcome-color-primary);
color: var(--welcome-color-white);
text-decoration: none;
}
}
.onboarding-welcome-image {
@include box-shadow;
border-radius: 20px;
width: 100%;
height: 500px;
object-fit: cover;
}
.service-card {
@include box-shadow;
display: flex;
padding: 40px;
column-gap: 86px;
border-radius: 20px;
background-color: var(--welcome-color-white);
position: relative;
z-index: 2;
h3 {
color: var(--welcome-color-primary);
font-size: 20px;
margin: 0;
}
p {
color: var(--welcome-color-gray);
font-size: 16px;
margin-bottom: 0;
}
.text-link {
text-decoration: underline !important;
font-weight: 600 !important;
color: var(--welcome-color-gray);
}
.service-card-image, .service-card-image-video-overlay {
position: relative;
width: 274px;
height: 154px;
}
.service-card-text {
display: flex;
flex-direction: column;
row-gap: 20px;
}
.service-card-image-video-overlay {
display: none;
}
&:hover, &.highlighted {
.service-card-image-video-overlay.active {
position: absolute;
display: block;
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.5);
color: var(--welcome-color-white);
font-size: 80px;
cursor: pointer;
}
}
}
.line-curve {
position: absolute;
z-index: 0;
right: -50%;
bottom: -50%;
height: 137.9px !important;
}
.others-card-container {
margin-top: 40px;
flex-wrap: wrap;
justify-content: space-between;
display: grid;
gap: 32px;
&.multi-card {
grid-template-columns: 1fr 1fr 1fr;
}
}
.others-card {
@include box-shadow;
display: flex;
flex-direction: column;
padding: 24px;
row-gap: 15px;
background-color: var(--welcome-color-white);
border-radius: 20px;
align-items: start;
h3 {
font-size: 24px;
font-weight: 600;
color: var(--welcome-color-primary-alt);
}
p {
font-size: 16px;
color: var(--welcome-color-gray);
line-height: normal;
}
a {
color: var(--welcome-color-primary-alt);
font-size: 16px;
font-weight: 500;
text-decoration: none;
&:hover {
text-decoration: none;
color: var(--welcome-color-primary-dark);
}
}
.links-list {
display: flex;
column-gap: 24px;
margin-top: auto;
}
}
.optional-link {
color: var(--welcome-color-primary-alt);
text-decoration: underline;
position: relative;
font-size: 16px;
font-weight: bold;
padding: 9px 20px;
&::after {
content: " >";
font-weight: bold;
position: absolute;
right: 8px;
}
&:hover {
color: var(--welcome-color-primary-dark);
}
}
.scroll-up {
position: fixed;
margin: 30px;
bottom: 0;
right: 0;
z-index: 3;
}
.welcome-video-modal {
.modal-dialog {
max-width: 1800px; /* default for 4K and up */
}
@media (max-width: 2200px) {
.modal-dialog {
max-width: 1140px; /* for FHD */
max-height: calc(100% - 3.5rem);
}
}
@media (max-width: 1366px) {
.modal-dialog {
max-width: 880px; /* for smaller screens */
}
}
.modal-content {
display: flex;
padding: 20px 40px 40px 40px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
border-radius: 20px;
background: var(--welcome-color-white);
box-shadow: 0px 15px 30px 0px rgba(0, 0, 0, 0.15);
}
.welcome-video-header {
display: flex;
height: 32px;
flex-direction: column;
align-items: flex-end;
gap: 12px;
align-self: stretch;
.modal-close {
width: 34px;
height: 32px;
opacity: 1;
i {
font-size: 32px;
color: var(--welcome-color-primary);
}
&:hover {
color: var(--welcome-color-primary-hover);
}
}
}
.welcome-video-body {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.video-container {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 ratio */
height: 0;
overflow: hidden;
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
.welcome-video-thumbs {
display: flex;
align-items: flex-start;
gap: 12px;
align-self: stretch;
.welcome-video-thumb {
position: relative;
display: flex;
width: 240px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
cursor: pointer;
.welcome-video-thumb-img {
height: 135px;
align-self: stretch;
aspect-ratio: 16/9;
.welcome-video-thumb-img-overlay {
background-color: rgba(0, 0, 0, 0.25);
}
i {
width: 32px;
height: 32px;
aspect-ratio: 1/1;
}
}
.welcome-video-thumb-play-overlay {
position: absolute;
width: 100%;
height: 135px;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.25);
display: flex;
justify-content: center;
align-items: center;
color: var(--welcome-color-white);
i {
font-size: 32px;
}
}
.welcome-video-thumb-title {
color: var(--welcome-color-primary-alt);
font-family: 'Open Sans Regular';
font-size: 20px;
font-style: normal;
font-weight: 600;
line-height: normal;
display: -webkit-box;
-webkit-line-clamp: 2; /* show only 2 lines */
-webkit-box-orient: vertical;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
&.checked {
.welcome-video-thumb-play-overlay {
display: none;
}
cursor: default;
}
}
}
@media (max-width: 2200px) {
.welcome-video-thumbs {
.welcome-video-thumb {
width: 176px !important;
gap: 8px;
}
.welcome-video-thumb-title {
font-size: 16px !important;
}
.welcome-video-thumb-img {
height: 99px !important;
}
.welcome-video-thumb-play-overlay {
height: 99px !important;
}
}
}
@media (max-width: 1366px) {
.modal-content {
padding: 12px 24px 24px 24px;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
// TIMELINE Module
// Load templates
const timelineTemplate = require('../templates/playlist-timeline.hbs');
const timelineInfoTemplate = require('../templates/playlist-timeline-info.hbs');
const timelineHeaderInfoTemplate =
require('../templates/playlist-timeline-header-info.hbs');
const defaultStepHeight = 4;
const minStepHeight = 2;
const maxStepHeight = 90;
const widgetMinHeight = 50;
const widgetDefaultHeight = 90;
/**
* Timeline contructor
* @param {object} container - the container to render the timeline to
* @param {object =} [options] - Timeline options
*/
const PlaylistTimeline = function(container) {
this.DOMObject = container;
// Set step height
this.stepHeight = defaultStepHeight;
// Set total height
this.totalTimelineHeight = 0;
// Timeline mode
this.scaledTimeline = false;
};
/**
* Render Timeline and the layout
*/
PlaylistTimeline.prototype.render = function() {
// Render timeline template
const html = timelineTemplate(
$.extend({}, pE.playlist, {trans: editorsTrans}),
);
// Append html to the main div
this.DOMObject.html(html);
// Calculate widget heights
this.calculateWidgetHeights();
// Create grid
this.createGrid();
// Update info
this.updateInfo();
// Enable select for each widget
this.DOMObject.find('.playlist-widget.selectable').on('click', function(e) {
e.stopPropagation();
if (!$(e.currentTarget).hasClass('to-be-saved')) {
pE.selectObject({target: $(e.currentTarget)});
}
});
this.DOMObject.find('.timeline-overlay-step').droppable({
greedy: true,
tolerance: 'pointer',
accept: (draggable) => {
// Check target
return pE.common.hasTarget(draggable, 'playlist');
},
drop: function(event, ui) {
const position = parseInt($(event.target).data('position')) + 1;
pE.playlist.addObject(event.target, ui.draggable[0], position);
},
});
this.DOMObject.find('.timeline-overlay-step').on('click', function(e) {
if (
!$.isEmptyObject(pE.toolbar.selectedCard) ||
!$.isEmptyObject(pE.toolbar.selectedQueue)
) {
e.stopPropagation();
const position = parseInt($(e.target).data('position')) + 1;
pE.selectObject({
target: $(e.target).parents('#playlist-timeline'),
reloadViewer: true,
positionToAdd: position,
});
}
});
this.DOMObject.find('.playlist-widget').droppable({
greedy: true,
tolerance: 'pointer',
accept: function(el) {
return (
$(this).hasClass('editable') &&
$(el).attr('drop-to') === 'widget'
) ||
(
$(this).hasClass('permissionsModifiable') &&
$(el).attr('drop-to') === 'all' &&
$(el).data('subType') === 'permissions'
);
},
drop: function(event, ui) {
pE.playlist.addObject(event.target, ui.draggable[0]);
},
});
// Handle widget attached audio click
this.DOMObject.find(
'.playlist-widget.editable .editProperty',
).on('click', function(e) {
e.stopPropagation();
const widget =
pE.getObjectByTypeAndId(
$(e.target).parents('.playlist-widget').data('type'),
$(e.target).parents('.playlist-widget').attr('id'),
$(e.target).parents('.playlist-widget').data('widgetRegion'),
);
widget.editPropertyForm(
$(e.target).data('property'),
$(e.target).data('propertyType'),
);
});
this.DOMObject.find('.playlist-widget').contextmenu(function(ev) {
if (
$(ev.currentTarget).is('.editable, .deletable, .permissionsModifiable')
) {
// Open context menu
pE.openContextMenu(ev.currentTarget, {
x: ev.pageX,
y: ev.pageY,
});
}
// Prevent browser menu to open
return false;
});
// Save order function with debounce
const saveOrderFunc = _.debounce(function() {
// Check if editor container is
// an empty object, if so, cancel
if ($.isEmptyObject(pE.editorContainer)) {
return;
}
pE.saveOrder();
pE.timeline.DOMObject.find('#unsaved').hide();
pE.timeline.DOMObject.find('#saved').show();
}, 1000);
// Sortable widgets
this.DOMObject.find('#timeline-container').sortable({
// Disabled if we're inline with a non-external playlist
// and on read only mode
disabled: (
pE.inline === true &&
pE.externalPlaylist === false &&
typeof lD != undefined &&
lD.readOnlyMode === true
),
axis: 'y',
items: '.playlist-widget',
start: function(event, ui) {
pE.timeline.DOMObject.find('#unsaved').hide();
saveOrderFunc.cancel();
pE.clearTemporaryData();
},
stop: function(event, ui) {
// Mark target as "to be saved"
$(ui.item).addClass('to-be-saved');
// Re-render grid after drag to calculate scale values
if (!pE.timeline.scaledTimeline) {
pE.timeline.createGrid();
}
pE.timeline.DOMObject.find('#unsaved').show();
// Save order
saveOrderFunc();
},
});
// Reload tooltips
pE.common.reloadTooltips(pE.timeline.DOMObject);
};
/**
* Create grid
*/
PlaylistTimeline.prototype.createGrid = function() {
const $stepWithValue =
$(`<div class="time-grid-step-with-value time-grid-step">
<div class="step-value"></div>
</div>`);
const $step =
$('<div class="time-grid-step"></div>');
const $timeGrid = this.DOMObject.siblings('.time-grid');
// Empty grid container
$timeGrid.empty();
// If we are in scaled mode
if (this.scaledTimeline) {
// Add steps until we fill the timeline
// or we reach the number of items
const timelineHeight = $timeGrid.parents('.editor-body').height() - 20;
const targetHeight = (timelineHeight > this.totalTimelineHeight) ?
timelineHeight : (this.totalTimelineHeight + this.stepHeight * 2);
let step = 0;
let stepDelta = 1;
let stepLabelDelta = 0;
// Calculate step show and label delta
if (this.stepHeight > 30) {
stepDelta = 1;
stepLabelDelta = 1;
} else if (this.stepHeight > 15) {
stepDelta = 1;
stepLabelDelta = 2;
} else if (this.stepHeight >= 7) {
stepDelta = 2;
stepLabelDelta = 4;
} else if (this.stepHeight >= 3) {
stepDelta = 5;
stepLabelDelta = 10;
} else if (this.stepHeight >= 2) {
stepDelta = 5;
stepLabelDelta = 20;
} else {
stepDelta = 10;
stepLabelDelta = 30;
}
// Set grid container gap to height minus step height (2px)
const calculatedGap = (stepDelta * this.stepHeight) - 2;
$timeGrid.css('gap', calculatedGap + 'px');
for (
let auxHeight = targetHeight;
auxHeight > 0;
auxHeight -= (stepDelta * this.stepHeight)
) {
if ( step % stepDelta === 0 ) {
if (step % stepLabelDelta === 0) {
// Format date on step
const stepFormatted = pE.common.timeFormat(step);
// Add a labelled step
$stepWithValue.find('.step-value').text(stepFormatted);
$timeGrid.append($stepWithValue.clone());
} else {
// Add a normal step
$timeGrid.append($step.clone());
}
}
// Increment step
step += stepDelta;
}
} else {
// Create first step
$stepWithValue.find('.step-value').text(
pE.common.timeFormat(0),
);
$timeGrid.append($stepWithValue.clone());
let durationSum = 0;
// Create a labelled value for each widget in the timeline
this.DOMObject.find('.playlist-widget').each(function(_idx, el) {
const $widget = $(el);
const duration = $widget.data('duration');
// Format date on step and add to sum
durationSum += duration;
const durationFormatted = pE.common.timeFormat(
durationSum,
);
// Add a labelled step
$stepWithValue.find('.step-value').text(durationFormatted);
$timeGrid.append($stepWithValue.clone());
});
// Timeline fixed gap minus step height (2px)
$timeGrid.css('gap', (widgetDefaultHeight - 2) + 'px');
}
};
/**
* Calculate widget heights
*/
PlaylistTimeline.prototype.calculateWidgetHeights = function() {
const self = this;
// If we are in scaled mode
if (this.scaledTimeline) {
// Reset total height
self.totalTimelineHeight = 0;
// Calculate widget heights
this.DOMObject.find('.playlist-widget').each(function(_idx, el) {
const $widget = $(el);
const duration = $widget.data('duration');
// Calculate height
const height = duration * self.stepHeight;
// If height is less than minimum, show replacement
if (height < widgetMinHeight) {
$widget.addClass('minimal-widget');
}
// Set height
$widget.css('height', height + 'px');
// Give same height to the dropdown step
self.DOMObject.find(`.timeline-overlay-dummy[data-position=${_idx}]`)
.css('height', height + 'px');
self.totalTimelineHeight += height;
});
} else {
// All widgets have default height
this.DOMObject.find('.playlist-widget')
.css('height', widgetDefaultHeight + 'px');
// Give same height to the dropdown step
this.DOMObject.find(`.timeline-overlay-dummy`)
.css('height', widgetDefaultHeight + 'px');
// Calculate timeline height
self.totalTimelineHeight =
this.DOMObject.find('.playlist-widget').length *
widgetDefaultHeight;
}
};
/**
* Change playlist zoom level
* @param {number} zoomLevelChange
*/
PlaylistTimeline.prototype.changeZoomLevel = function(zoomLevelChange) {
let zoomLevelChangeStep = 2;
// If we're working with higher zooms, increase step change
if (this.stepHeight >= 30) {
zoomLevelChangeStep = 20;
} else if (this.stepHeight >= 10) {
zoomLevelChangeStep = 4;
} else {
zoomLevelChangeStep = 2;
}
// Calculate new zoom level
// If zoomLevelChange is 0, it means we are resetting the zoom level
this.stepHeight =
(zoomLevelChange === 0) ?
defaultStepHeight :
this.stepHeight + zoomLevelChange * zoomLevelChangeStep;
// Clamp zoom level between min and max
this.stepHeight =
Math.min(Math.max(this.stepHeight, minStepHeight), maxStepHeight);
// Render timeline
this.render();
};
/**
* Update information about the current playlist
*/
PlaylistTimeline.prototype.updateInfo = function() {
// Format playlist duration
pE.playlist.durationFormatted = pE.common.timeFormat(pE.playlist.duration);
// Render timeline template
const html = timelineInfoTemplate(
$.extend({}, {
playlist: pE.playlist,
widget: pE.selectedObject,
}, {trans: toolbarTrans}),
);
const widgets = pE.playlist?.widgets || {};
const headerHtml = timelineHeaderInfoTemplate(
$.extend({}, {
playlist: pE.playlist,
widgetsCount: Object.keys(widgets).length,
}, {trans: {
...playlistEditorTrans,
editPlaylistTitle: playlistEditorTrans.editPlaylistTitle.replace(
'%playlistName%',
pE.playlist.name,
),
}}),
);
// Inject HTML into container
this.DOMObject.parents('#playlist-editor')
.find('.selected-info').html(html);
this.DOMObject.parents('.editor-modal')
.find('.modal-header--left').html(headerHtml);
};
/**
* Switch timeline scale mode
* @param {boolean} force
*/
PlaylistTimeline.prototype.switchScaleMode = function(force) {
// Switch flag
this.scaledTimeline = (force) ? force: !this.scaledTimeline;
// If switched on, show scale controls
pE.editorContainer.toggleClass('timeline-scaled', this.scaledTimeline);
// Render again
this.render();
};
module.exports = PlaylistTimeline;

View File

@@ -0,0 +1,495 @@
/*
* 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/>.
*/
// PLAYLIST Module
const Widget = require('../editor-core/widget.js');
/**
* Playlist contructor
* @param {number} id - Playlist id
* @param {object} data - data to build the playlist object
*/
const Playlist = function(id, data) {
// Playlist name
this.name = data.name;
// properties
this.playlistId = id;
this.folderId = data.folderId;
this.isEmpty = true;
this.regionId = data.regionId;
this.isTopLevel = (data.regionId != 0);
this.widgets = {};
this.duration = null;
this.folderId = data.folderId;
// Create data structure based on the API data
this.createDataStructure(data);
// Calculate duration, looping, and all properties related to time
this.calculateTimeValues();
};
/**
* Create data structure
* @param {object} data - data to build the playlist object
*/
Playlist.prototype.createDataStructure = function(data) {
// Playlist duration calculated based on the longest region duration
let playlistDuration = 0;
// Widget's data
const widgets = data.widgets;
// Create widgets for this region
for (const widget in widgets) {
if (Object.prototype.hasOwnProperty.call(widgets, widget)) {
const newWidget = new Widget(
widgets[widget].widgetId,
widgets[widget],
);
if (newWidget.subType == 'image' || newWidget.subType == 'video') {
newWidget.previewSrc =
imageDownloadUrl.replace(':id', widgets[widget].mediaIds[0]);
}
// Save designer object for later use
newWidget.editorObject = pE;
// Save parent region
newWidget.parent = this;
// calculate expire status
newWidget.calculateExpireStatus();
// Check if widget is enabled
newWidget.checkIfEnabled();
// Format duration
newWidget.calculatedDurationFormatted =
pE.common.timeFormat(newWidget.calculatedDuration);
// Add newWidget to the playlist widget object
this.widgets[newWidget.id] = newWidget;
// Mark the playlist as not empty
this.isEmpty = false;
// Increase playlist Duration
playlistDuration += newWidget.getTotalDuration();
}
}
// Set playlist duration
this.duration = playlistDuration;
};
/**
* Calculate timeline values ( duration, loops )
* based on widget and region duration
*/
Playlist.prototype.calculateTimeValues = function() {
// Widgets
const widgets = this.widgets;
let loopSingleWidget = false;
let singleWidget = false;
// If there is only one widget in the playlist
// check the loop option for that region
if (widgets.length === 1) {
singleWidget = true;
// Check the loop option
for (const option in this.options) {
if (
this.options[option].option === 'loop' &&
this.options[option].value === '1'
) {
this.loop = true;
loopSingleWidget = true;
break;
}
}
} else if (parseFloat(this.duration) < parseFloat(this.duration)) {
// if the region duration is less than the layout duration enable loop
this.loop = true;
}
for (const widget in widgets) {
if (Object.prototype.hasOwnProperty.call(widgets, widget)) {
const currWidget = widgets[widget];
// If the widget needs to be extended
currWidget.singleWidget = singleWidget;
currWidget.loop = loopSingleWidget;
}
}
};
/**
* Add action to take after dropping a draggable item
* @param {object} _droppable - Target drop object
* @param {object} draggable - Dragged object
* @param {number=} addToPosition - Add to specific position in the widget list
*/
Playlist.prototype.addObject = function(
_droppable,
draggable,
addToPosition = null,
) {
const draggableType = $(draggable).data('type');
const draggableSubType = $(draggable).data('subType');
const draggableData = $(draggable).data();
// Get playlist Id
const playlistId = this.playlistId;
// Add dragged item to region
if (draggableType == 'media') { // Adding media from search tab to a region
if ($(draggable).hasClass('from-provider')) {
pE.importFromProvider([$(draggable).data('providerData')]).then((res) => {
// If res is empty, it means that the import failed
if (res.length === 0) {
console.error(errorMessagesTrans.failedToImportMedia);
} else {
this.addMedia(res, addToPosition);
}
}).catch(function() {
toastr.error(errorMessagesTrans.importingMediaFailed);
});
} else {
this.addMedia($(draggable).data('mediaId'), addToPosition);
}
} else { // Add widget/module/template
// Get regionSpecific property
const regionSpecific = $(draggable).data('regionSpecific');
// Upload form if not region specific
if (regionSpecific == 0) {
const validExt = $(draggable).data('validExt').replace(/,/g, '|');
openUploadForm({
url: libraryAddUrl,
title: uploadTrans.uploadMessage,
animateDialog: false,
initialisedBy: 'playlist-editor-upload',
className: 'second-dialog',
buttons: {
main: {
label: translations.done,
className: 'btn-primary btn-bb-main',
callback: function() {
pE.reloadData();
},
},
},
templateOptions: {
trans: uploadTrans,
upload: {
maxSize: $(draggable).data().maxSize,
maxSizeMessage: $(draggable).data().maxSizeMessage,
validExtensionsMessage: translations.validExtensions
.replace('%s', $(draggable).data('validExt')),
validExt: validExt,
},
playlistId: playlistId,
displayOrder: addToPosition,
currentWorkingFolderId: pE.folderId,
showWidgetDates: true,
folderSelector: true,
},
}).attr('data-test', 'uploadFormModal');
} else { // Add widget to a region
const linkToAPI = urlsForApi.playlist.addWidget;
let requestPath = linkToAPI.url;
pE.common.showLoadingScreen();
// Replace type
requestPath = requestPath.replace(':type', draggableSubType);
// Replace playlist id
requestPath = requestPath.replace(':id', playlistId);
// Set position to add if selected
let addOptions = null;
if (addToPosition != null) {
addOptions = {
displayOrder: addToPosition,
};
}
// Set template if if exists
if (draggableData.templateId) {
addOptions = addOptions || {};
addOptions.templateId = draggableData.templateId;
}
pE.historyManager.addChange(
'addWidget',
'playlist', // targetType
playlistId, // targetId
null, // oldValues
addOptions, // newValues
{
updateTargetId: true,
updateTargetType: 'widget',
customRequestPath: {
url: requestPath,
type: linkToAPI.type,
},
},
).then((res) => { // Success
pE.common.hideLoadingScreen();
// The new selected object
pE.selectedObject.id = 'widget_' + res.data.widgetId;
pE.selectedObject.type = 'widget';
// If we're adding a specific playlist, we need to
// update playlist values in the new widget
const subPlaylistId = $(draggable).data('subPlaylistId');
if (subPlaylistId) {
pE.historyManager.addChange(
'saveForm',
'widget', // targetType
res.data.widgetId, // targetId
null, // oldValues
{
subPlaylists: JSON.stringify([
{
rowNo: 1,
playlistId: subPlaylistId,
spots: '',
spotLength: '',
spotFill: 'repeat',
},
]),
}, // newValues
{
addToHistory: false,
},
).then((_res) => {
pE.reloadData();
}).catch((_error) => {
toastr.error(_error);
// Delete newly added widget
pE.deleteObject('widget', res.data.widgetId);
});
} else {
pE.reloadData();
}
}).catch((error) => { // Fail/error
pE.common.hideLoadingScreen();
// Show error returned or custom message to the user
let errorMessage = '';
if (typeof error == 'string') {
errorMessage += error;
} else {
errorMessage += error.errorThrown;
}
// Remove added change from the history manager
pE.historyManager.removeLastChange();
// Show toast message
toastr.error(errorMessage);
});
}
}
};
/**
* Add media to the playlist
* @param {Array.<number>} media
* @param {number=} addToPosition
*/
Playlist.prototype.addMedia = function(media, addToPosition = null) {
// Get playlist Id
const playlistId = this.playlistId;
// Get media Id
let mediaToAdd = {};
if (Array.isArray(media)) {
mediaToAdd = {
media: media,
};
} else {
mediaToAdd = {
media: [
media,
],
};
}
// Check if library duration options exists and add it to the query
if (pE.useLibraryDuration != undefined) {
mediaToAdd.useDuration = (pE.useLibraryDuration == '1');
}
// Show loading screen in the dropzone
pE.showLocalLoadingScreen();
pE.common.showLoadingScreen();
// Set position to add if selected
if (addToPosition != null) {
mediaToAdd.displayOrder = addToPosition;
}
// Create change to be uploaded
pE.historyManager.addChange(
'addMedia',
'playlist', // targetType
playlistId, // targetId
null, // oldValues
mediaToAdd, // newValues
{
updateTargetId: true,
updateTargetType: 'widget',
},
).then((res) => { // Success
pE.common.hideLoadingScreen();
// The new selected object
pE.selectedObject.id = 'widget_' + res.data.newWidgets[0].widgetId;
pE.selectedObject.type = 'widget';
pE.reloadData();
}).catch((error) => { // Fail/error
pE.common.hideLoadingScreen();
// Show error returned or custom message to the user
let errorMessage = '';
if (typeof error == 'string') {
errorMessage = error;
} else {
errorMessage = error.errorThrown;
}
// Show toast message
toastr.error(errorMessagesTrans.addMediaFailed
.replace('%error%', errorMessage));
});
};
/**
* Delete an object in the playlist, by ID
* @param {string} objectType - object type (widget, region, ...)
* @param {number} objectId - object id
* @return {Promise} - Promise object
*/
Playlist.prototype.deleteObject = function(
objectType,
objectId,
) {
pE.common.showLoadingScreen();
// Remove changes from the history array
return pE.historyManager.removeAllChanges(
objectType,
objectId,
).then((_res) => {
pE.common.hideLoadingScreen();
// Create a delete type change
// upload it but don't add it to the history array
return pE.historyManager.addChange(
'delete',
objectType, // targetType
objectId, // targetId
null, // oldValues
null, // newValues
{
addToHistory: false, // options.addToHistory
},
);
}).catch(function() {
pE.common.hideLoadingScreen();
toastr.error(errorMessagesTrans.removeAllChangesFailed);
});
};
/**
* Save playlist order
* @param {object} widgets - Widgets DOM objects array
* @return {Promise} - Promise object
*/
Playlist.prototype.saveOrder = function(widgets) {
if ($.isEmptyObject(pE.playlist.widgets)) {
return Promise.resolve({
message: errorMessagesTrans.noWidgetsNeedSaving,
});
}
// Get playlist's widgets previous order
const oldOrder = {};
let orderIndex = 1;
for (const widget in pE.playlist.widgets) {
if (pE.playlist.widgets.hasOwnProperty(widget)) {
oldOrder[pE.playlist.widgets[widget].widgetId] = orderIndex;
orderIndex++;
}
}
// Get new order
const newOrder = {};
for (let index = 0; index < widgets.length; index++) {
const widget = pE.getObjectByTypeAndId(
'widget',
$(widgets[index]).attr('id'),
);
newOrder[widget.widgetId] = index + 1;
}
if (JSON.stringify(newOrder) === JSON.stringify(oldOrder)) {
return Promise.resolve({
message: errorMessagesTrans.listOrderNotChanged,
});
}
return pE.historyManager.addChange(
'order',
'playlist',
this.playlistId,
{
widgets: oldOrder,
},
{
widgets: newOrder,
},
).catch((error) => {
toastr.error(errorMessagesTrans.playlistOrderSave);
console.error(error);
});
};
module.exports = Playlist;

13
ui/src/style/bootstrap_theme.scss vendored Normal file
View File

@@ -0,0 +1,13 @@
// Bootstrap theme colours
$blue: #337ab7;
@import '~bootstrap/scss/bootstrap.scss';
.card-columns {
@include media-breakpoint-only(lg) {
column-count: 3;
}
@include media-breakpoint-only(xl) {
column-count: 4;
}
}

172
ui/src/style/bottombar.scss Normal file
View File

@@ -0,0 +1,172 @@
@import "variables";
@import "mixins";
.editor-bottom-bar {
height: calc($viewer-bottom-bar-height + $viewer-bottom-bar-border-width);
margin: 0px 16px 16px 16px;
nav {
border: $viewer-bottom-bar-border-width solid $xibo-color-primary-l60;
border-top: 0;
color: $xibo-color-primary;
min-height: $viewer-bottom-bar-height;
font-size: 0;
z-index: $viewer-bottom-bar-z-index;
margin: 0;
background: $xibo-color-neutral-0;
right: 0;
left: 0;
@include border-radius(0 0 4px 4px);
flex-direction: column;
span, .info {
font-size: 1rem;
}
.divider {
display: inline-block;
height: 40px;
vertical-align: middle;
border-right: 3px solid $xibo-color-primary-l5;
margin: 0 3px;
}
.btn {
color: $xibo-color-secondary;
width: 38px;
font-size: 1.2rem;
border: none;
border-radius: 0;
&:hover:not(:disabled) {
color: lighten($xibo-color-secondary, 10%);
}
&:disabled, &.disabled {
opacity: 0.4;
}
&#delete-btn {
color: $xibo-color-neutral-0;
background-color: $xibo-color-semantic-error;
&:hover {
background-color: darken($xibo-color-semantic-error, 10%);
}
}
&#undo-btn {
color: $xibo-color-neutral-1000;
background-color: $xibo-color-semantic-warning;
&:hover {
background-color: darken($xibo-color-semantic-warning, 10%);
}
}
}
.hide-on-fs {
display: inline-block;
}
.show-on-fs {
display: none;
}
.viewer-navbar-controls, .viewer-navbar-info {
width: 100%;
display: flex;
align-items: center;
height: calc($viewer-bottom-bar-height / 2);
padding: 0 1rem;
}
.viewer-navbar-controls {
justify-content: space-between;
}
.viewer-navbar-info {
margin: 0;
color: $xibo-color-primary-l5;
background-color: $xibo-color-primary;
.info {
width: 100%;
.fa-arrow-right {
margin: 0 12px;
}
.label-name {
display: flex;
gap: 8px;
& > .name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& > .mediaTemplate {
white-space: nowrap;
}
& > .mediaInfo {
display: inline-flex;
gap: 6px;
overflow: hidden;
.mediaInfoName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.btn {
color: $xibo-color-primary-l5;
height: 38px;
width: 38px;
font-size: 1.2rem;
}
}
&.designer-layout {
.viewer-navbar-info {
display: none;
}
}
}
#inline-editor-save {
display: none;
}
}
#layout-editor.fullscreen-mode {
.editor-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: $toolbar-card-z-index;
margin: 0;
&.fs-edit {
#inline-editor-save {
display: inline-block;
}
}
.hide-on-fs {
display: none;
}
.show-on-fs {
display: inline-block;
}
}
}

View File

@@ -0,0 +1,42 @@
/*!
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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/>.
*/
// Imports
@import "variables";
@import "mixins";
@import "header-center-logo";
#campaign-builder {
margin-top: -30px;
}
#campaign-builder {
// Put the back button at the top left.
.back-button {
position: relative;
top: -12px;
left: 0;
span {
margin-left: 6px;
font-weight: bold;
}
}
}

372
ui/src/style/common.scss Normal file
View File

@@ -0,0 +1,372 @@
/*!
* 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/>.
*/
// Imports
@import "mixins";
@import "variables";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/brands";
@import "~@fortawesome/fontawesome-free/scss/solid";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
// CSS
/* Tab System */
.form-container {
.nav>li>a {
color: $xibo-color-neutral-900;
background-color: lighten($xibo-color-neutral-900, 30%);
@include border-radius(0);
border: 1px solid $xibo-color-neutral-100 !important;
padding: 5px 7px;
&:hover {
color: $xibo-color-neutral-100;
background-color: $xibo-color-primary !important;
}
&.active,
&.active:hover {
background-color: $xibo-color-neutral-100 !important;
color: $xibo-color-primary;
}
}
.tab-pane {
padding-top: 5px;
}
/* Form drag and drop list */
.connectedlist .ui-sortable {
background-color: $xibo-color-primary;
min-height: 80px;
}
.form-check {
display: flex;
gap: 6px;
}
.form-check-label {
flex-grow: 1;
}
.control-label {
& > strong {
+ span[data-toggle="popover"] {
margin-left: 10px;
}
}
}
.colorpicker-form-element.colorpicker-input {
.picker-container {
position: relative;
}
}
}
/* Tooltip colour */
div.tooltip.bs-tooltip-auto {
.tooltip-inner {
background-color: darken($xibo-color-secondary, 10%);
}
&[x-placement^=right] .arrow::before {
border-right-color: darken($xibo-color-secondary, 10%);
}
&[x-placement^=left] .arrow::before {
border-left-color: darken($xibo-color-secondary, 10%);
}
&[x-placement^=top] .arrow::before {
border-top-color: darken($xibo-color-secondary, 10%);
}
&[x-placement^=bottom] .arrow::before {
border-bottom-color: darken($xibo-color-secondary, 10%);
}
}
.no-user-select {
@include user-select-none();
}
// Minimum resolution message
.min-res-message {
position: fixed;
top: 0;
left: 0;
z-index: $min-res-message-z-index;
height: calc(100vh - 50px);
align-content: center;
margin-left: 20vw;
width: 80vw;
&>div {
position: relative;
text-align: center;
height: 40%;
width: 80%;
color: $xibo-color-neutral-900;
background-color: $xibo-color-neutral-0;
padding: 40px;
border-radius: 8px;
display: flex;
align-content: center;
align-items: center;
justify-content: center;
flex-direction: column;
outline: 4px solid $xibo-color-primary;
h4 {
margin-bottom: 8px;
}
.close-res-message-button {
margin-top: 32px;
}
}
}
.min-res-overlay {
display: block !important;
z-index: $min-res-message-overlay-z-index !important;
}
@media (max-width: 768px) {
.min-res-message {
height: calc(100vh - 150px);
}
}
/* Layout Manager */
#layout-manager {
left: 10px;
top: 10px;
z-index: 1;
position: fixed;
width: 220px;
opacity: 0.8;
#layout-manager-header {
background: #2d2d2d;
color: white;
font-weight: bold;
padding: 5px;
border: #212121 3px solid;
}
#layout-manager-container {
display: grid;
grid-template-columns: auto;
grid-gap: 2px;
color: #444;
padding: 2px;
background: #303030;
border: #3e3e3e 4px solid;
}
.title {
color: white;
font-weight: bold;
background-color: #4d4d4d;
cursor: move;
text-align: center;
}
.label {
display: inline-block;
width: 48%;
}
.change {
color: black;
background-color: #c3c3c3;
padding: 2px;
@include border-radius(2px);
}
.change.uploaded {
background-color: #8dffa6;
}
}
// Overlay and loading
.custom-overlay,
.custom-overlay-clone,
.loading-overlay,
.custom-overlay-edit-text,
.custom-overlay-action-widget-edit {
display: none;
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: black;
@include transparent-object(65);
z-index: $loading-overlay-z-index;
}
.custom-overlay-action-widget-edit {
position: absolute;
background: white;
}
.custom-overlay-edit-text, .custom-overlay-action-widget-edit {
display: block !important;
@include transparent-object(10);
}
.custom-overlay, .custom-overlay-clone {
z-index: $custom-overlay-z-index;
}
.loading-overlay {
top: auto;
bottom: 0;
width: 100px;
height: 100px;
border-radius: 0 12px 0 0;
}
.loading-overlay.loading {
z-index: $loading-overlay-z-index;
}
.loading-overlay.loading .loading-icon {
display: block;
font-size: 4.3rem;
position: absolute;
left: 20px;
bottom: 20px;
color: #ffffff;
}
/* Context menu */
.context-menu-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: $context-menu-overlay-z-index;
.context-menu {
position: absolute;
z-index: ($context-menu-overlay-z-index + 1);
background: $xibo-color-neutral-0;
@include box-shadow(4px 4px 8px $xibo-color-shadow);
.sort-controls-container {
text-align: center;
background: $xibo-color-secondary;
.context-menu-btn {
color: $xibo-color-primary-l60;
&:hover {
color: $xibo-color-primary;
}
}
}
.context-menu-btn {
display: inline-block;
font-size: 1rem;
color: $xibo-color-primary;
padding: 6px 12px;
cursor: pointer;
& > span {
margin-left: 6px;
}
&:hover {
background-color: $xibo-color-primary-l10;
}
}
.deleteBtn, .deleteGroupElementsBtn{
color: lighten($xibo-color-semantic-error, 5%);
&:hover {
color: $xibo-color-semantic-error;
}
}
}
}
/* Tools icons */
.tool-icon-region {
@extend .fa, .fa-clone;
}
.tool-icon-audio {
@extend .fa, .fa-volume-up;
}
.tool-icon-expiry {
@extend .fa, .fa-calendar-check-o;
}
.tool-icon-transitionIn {
@extend .fa, .fa-sign-in;
}
.tool-icon-transitionOut {
@extend .fa, .fa-sign-out;
}
.tool-icon-permissions {
@extend .fa, .fa-user-secret;
}
/* Form icons */
.bg_not_found_icon {
@extend .fa, .fa-exclamation-triangle;
padding: 0 5px;
}
/* Toolbar level icons */
.toolbar-level-icon {
background-size: 20px 20px;
background-repeat: no-repeat;
background-position: center;
}
.toolbar-level-control-1 {
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A");
}
.toolbar-level-control-2 {
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A");
}
.toolbar-level-control-3 {
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff8' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A");
}
.toolbar-level-control-4 {
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Generator: Adobe Illustrator 27.8.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --%3E%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 20 20' style='enable-background:new 0 0 20 20;' xml:space='preserve'%3E%3Cg%3E%3Cpath fill='%23fff' d='M3.3,20H0.8C0.4,20,0,19.6,0,19.2V0.8C0,0.4,0.4,0,0.8,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C4.1,19.6,3.7,20,3.3,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M8.6,20H6.1c-0.4,0-0.8-0.4-0.8-0.8V0.8C5.3,0.4,5.7,0,6.1,0h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4C9.4,19.6,9.1,20,8.6,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M13.9,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5c0.4,0,0.8,0.4,0.8,0.8v18.4 C14.7,19.6,14.3,20,13.9,20z'/%3E%3C/g%3E%3Cg%3E%3Cpath fill='%23fff' d='M19.2,20h-2.5c-0.4,0-0.8-0.4-0.8-0.8V0.8c0-0.4,0.4-0.8,0.8-0.8h2.5C19.6,0,20,0.4,20,0.8v18.4C20,19.6,19.6,20,19.2,20z' /%3E%3C/g%3E%3C/svg%3E%0A");
}

1030
ui/src/style/forms.scss Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
/*!
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* 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/>.
*/
/* Page content wrapper */
#content-wrapper .page-content > .row {
> div {
margin: 0;
}
&.header.header-side {
height: 50px;
margin-bottom: 0;
.navbar-toggler-side {
margin-top: 8px;
margin-right: 8px;
}
.user {
& > .item {
width: auto;
height: 50px;
padding-right: 16px;
& > a {
padding: 7px 0;
}
img.nav-avatar {
width: 36px;
height: 36px;
border-radius: 2px;
}
}
}
.xibo-logo-container {
margin-left: 50%;
transform: translateX(-50%);
.page {
width: auto;
padding: 0;
.xibo-logo {
height: 30px;
margin: 10px 0;
}
}
}
.user-notif > .item > a {
padding: 5px 0;
}
}
}

423
ui/src/style/help-pane.scss Normal file
View File

@@ -0,0 +1,423 @@
/*!
* 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/>.
*/
@import "variables";
.help-pane {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
&-loader {
position: absolute;
width: 100%;
height: 100%;
background-color: $xibo-color-overlay-light;
color: $xibo-color-primary-l60;
top: 0;
left: 0;
font-size: 80px;
display: flex;
justify-content: center;
align-items: center;
}
&-card-header {
display: flex;
height: 34px;
padding: 8px 16px;
align-items: center;
gap: 16px;
flex-shrink: 0;
border-bottom: 1px solid $xibo-color-neutral-500;
background: $xibo-color-neutral-300;
.card-header-title {
flex: 1 0 0;
line-height: 18px;
}
.back-icon, .close-icon {
display: flex;
width: 24px;
height: 24px;
justify-content: center;
align-items: center;
flex-shrink: 0;
color: $xibo-color-neutral-900;
cursor: pointer;
&:hover {
color: $xibo-color-neutral-1000;
}
}
}
&-container {
position: relative;
width: 400px;
display: none;
flex-direction: column;
align-items: flex-start;
border-radius: 3.5px;
border: 1px solid $xibo-color-neutral-700;
background: $xibo-color-neutral-0;
overflow: hidden;
max-height: calc(100vh - 100px);
overflow-y: auto;
.list-group-cards {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
background: $xibo-color-neutral-500;
.list-group-card-container {
display: flex;
padding: 12px 16px;
align-self: stretch;
background: $xibo-color-neutral-0;
.list-group-card {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
.list-group-link {
color: $xibo-color-primary;
}
.list-group-summary {
color: $xibo-color-neutral-900;
line-height: 18px;
}
}
}
}
.list-group-padded-cards {
display: flex;
flex-direction: column;
padding: 16px;
gap: 8px;
.help-pane-card {
display: flex;
padding: 8px;
align-items: center;
gap: 8px;
align-self: stretch;
border-radius: 3.5px;
border: 1px solid $xibo-color-neutral-300;
background: $xibo-color-neutral-0;
text-decoration: none;
cursor: pointer;
&-preview {
display: flex;
width: 40px;
height: 40px;
justify-content: center;
align-items: center;
border-radius: 3.5px;
background: $xibo-color-primary;
i {
color: $xibo-color-neutral-0;
font-size: 24px;
}
}
&-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
&-title, &-desc {
align-self: stretch;
line-height: 18px;
}
&-title {
color: $xibo-color-neutral-900;
}
&-desc {
color: $xibo-color-primary;
}
&:hover {
background: $xibo-color-primary-l20;
.help-pane-card-preview {
background: $xibo-color-primary-d40;
}
}
&:focus {
border-color: $xibo-color-primary-d60;
}
}
}
}
&-feedback-form {
display: flex;
padding: 16px;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
gap: 16px;
align-self: stretch;
background: $xibo-color-neutral-0;
& > *:not(.btn) {
align-self: stretch;
}
.xibo-form-input {
margin-bottom: 0;
&.invalid {
input, textarea {
border-color: $bootstrap-theme-error-color;
}
.error-message {
color: $bootstrap-theme-error-color;
font-size: 13px;
}
}
}
.file-uploader-attachments {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-bottom: 0;
.control-label {
margin-bottom: 8px;
}
.uploads-area {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
.uploads-drop {
display: flex;
padding: 16px 24px;
justify-content: center;
align-items: center;
gap: 16px;
align-self: stretch;
border-radius: 3.5px;
border: 1px dashed $xibo-color-primary;
i {
font-size: 32px;
opacity: 0.6;
color: $xibo-color-primary;
}
.upload-text {
display: flex;
align-items: flex-start;
align-content: flex-start;
gap: 4px;
flex: 1 0 0;
flex-wrap: wrap;
color: $xibo-color-neutral-700;
.upload-text-browse {
color: $xibo-color-primary;
cursor: pointer;
&:hover {
color: $xibo-color-secondary;
}
}
}
&.highlight {
border-style: solid;
background: $xibo-color-primary-l30;
}
}
.uploads-file-info {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-self: stretch;
color: $xibo-color-neutral-700;
}
}
.help-pane-upload-files {
display: flex;
padding: 8px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
border-radius: 3.5px;
background: $xibo-color-neutral-500;
.help-pane-upload-file {
display: flex;
padding: 8px;
align-items: center;
gap: 8px;
align-self: stretch;
border-radius: 3.5px;
border: 1px solid $xibo-color-neutral-500;
background: $xibo-color-neutral-0;
&-preview {
display: flex;
width: 40px;
height: 40px;
flex: 0 0 40px;
justify-content: center;
align-items: center;
overflow: hidden;
background: $xibo-color-primary;
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
i {
font-size: 24px;
color: $xibo-color-neutral-0;
}
}
&-info {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1 1 auto;
min-width: 0
}
&-name {
align-self: stretch;
color: $xibo-color-neutral-900;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-type {
align-self: stretch;
color: $xibo-color-primary;
}
.remove-file-icon {
display: flex;
width: 24px;
height: 24px;
flex: 0 0 24px;
padding: 4px;
font-size: 16px;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
color: $xibo-color-neutral-700;
cursor: pointer;
&:hover {
color: $xibo-color-neutral-900;
}
}
}
}
}
}
&-alert {
display: flex;
padding: 16px;
gap: 8px;
align-items: center;
margin-bottom: 0;
font-size: 16px;
.alert-break-text {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
}
&-btn {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
background-color: $xibo-color-primary;
width: 48px;
height: 48px;
border-radius: 50px;
cursor: pointer;
&:hover {
background-color: $xibo-color-primary-d40;
}
&.active {
background-color: $xibo-color-primary-d60;
}
i {
font-size: 24px;
color: $xibo-color-neutral-0;
}
}
&-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
background: rgba(0, 0, 0, 0);
}
}

File diff suppressed because it is too large Load Diff

55
ui/src/style/mixins.scss Normal file
View File

@@ -0,0 +1,55 @@
// Reusable LESS mixins
@mixin border-radius($radius) {
-webkit-border-radius: $radius;
-moz-border-radius: $radius;
border-radius: $radius;
}
@mixin user-select-none() {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */
}
@mixin box-shadow($params) {
-moz-box-shadow: $params !important;
-webkit-box-shadow: $params !important;
box-shadow: $params !important;
}
@mixin set-transparent-color($property, $color, $opacity) {
#{$property}: $color;
#{$property}: rgba($color, $opacity);
}
@mixin background-stripes($angle, $main-line-color, $secondary-line-color, $main-line-thickness, $secondary-line-thickness) {
background: repeating-linear-gradient(
$angle,
$main-line-color,
$main-line-color $main-line-thickness,
$secondary-line-color $main-line-thickness,
$secondary-line-color ($main-line-thickness + $secondary-line-thickness)
) !important;
}
@mixin transparent-object($opacity) {
/* IE */
filter: alpha(opacity= $opacity);
/* Netscape */
-moz-opacity: $opacity/100;
/* Safari 1x */
-khtml-opacity: $opacity/100;
/* Good browsers */
opacity: $opacity/100;
}
@mixin z-index-set($index) {
z-index: $index !important;
position: relative;
}

View File

@@ -0,0 +1,858 @@
// Playlist Editor ( separate from layout editor )
// Imports
@import "variables";
@import "mixins";
// Variables
$playlist-editor-main-background-color: $xibo-color-primary-l5;
$playlist-editor-main-fb-color: $xibo-color-neutral-900;
$playlist-editor-widget-bg-color: $xibo-color-primary-l5;
$playlist-editor-widget-fg-color: $xibo-color-neutral-900;
$playlist-editor-widget-fg-color2: $xibo-color-neutral-700;
$playlist-editor-widget-selected-color: lighten($xibo-color-semantic-success, 20%);
$playlist-editor-widget-multi-selected-color: $xibo-color-accent;
$playlist-editor-widget-multi-selected-hover-color: lighten($playlist-editor-widget-multi-selected-color, 20%);
$playlist-editor-widget-hover-bg-color: darken($xibo-color-primary-l5, 10%);
$playlist-editor-widget-border-color: $xibo-color-neutral-700;
$playlist-editor-playlist-hover-color-color: lighten($xibo-color-primary, 20%);
$playlist-editor-z-index-background: 1011;
$playlist-editor-z-index-overlay: 1012;
$playlist-editor-z-index-select: 1013;
$playlist-editor-z-index-select-hover: 1014;
$playlist-editor-z-index-help-button: 1015;
$playlist-editor-timeline-fg-color: $xibo-color-primary;
$playlist-editor-unsuccess-message-bg-color: $xibo-color-semantic-error;
$left-bar-width: 140px;
$bottom-bar-height: 38px;
$timeline-left-margin-width: 140px;
$timeline-step-height: 22px;
// CSS
.editor-modal {
display: block;
z-index: $playlist-editor-z-index-background;
overflow: auto;
@include set-transparent-color(background, $xibo-color-neutral-900, 0.6);
padding-right: 0;
padding-left: 60px;
.back-button {
position: fixed;
text-transform: uppercase;
left: 0;
top: 0;
z-index: 1;
background-color: $xibo-color-neutral-0;
height: 50px;
width: 60px;
a {
width: calc(100% - 1rem);
padding: 0.5rem;
color: $xibo-color-secondary;
margin-top: 4px;
margin-left: 6px;
&:hover {
background-color: $xibo-color-primary-l10;
}
}
span {
margin-left: 6px;
font-weight: bold;
display: none;
}
}
&.toolbar-opened {
.back-button {
a {
min-width: 100px;
width: auto;
}
span {
display: inline;
}
}
&[toolbar-level="1"] {
padding-left: 220px;
.back-button {
width: 220px;
}
}
&[toolbar-level="2"] {
padding-left: 350px;
.back-button {
width: 350px;
}
}
&[toolbar-level="3"] {
padding-left: 480px;
.back-button {
width: 480px;
}
}
&[toolbar-level="4"] {
padding-left: 660px;
.back-button {
width: 660px;
}
}
}
&.source-editor-opened {
.back-button {
z-index: auto;
}
.editor-side-bar nav.opened {
z-index: auto !important;
}
}
&.properties-panel-opened {
padding-right: 320px;
}
.editor-modal-dialog {
max-width: 100%;
height: calc(100% - 6rem);
margin: 3rem;
}
.editor-modal-content {
background: $playlist-editor-main-background-color;
height: 100%;
}
.editor-modal-header {
height: 50px;
padding: 0 1rem;
align-items: center;
gap: 8px;
&.modal-header {
.modal-header--left {
display: flex;
column-gap: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
.playlist-info-widgets,
.playlist-info-duration {
font-size: 1.5rem;
line-height: 1;
font-weight: bold;
}
}
.help-pane {
position: relative;
z-index: initial;
right: 40px;
.help-pane-container {
right: 0px;
top: 30px;
position: absolute;
z-index: $playlist-editor-z-index-help-button;
}
.help-pane-btn {
width: 34px;
height: 34px;
position: absolute;
left: 0px;
margin: 4px;
i {
font-size: 24px;
}
}
}
}
}
.editor-modal-body {
padding: 0;
}
.editor-modal-title {
line-height: 1;
font-weight: bold;
color: $playlist-editor-main-fb-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.container-designer {
height: calc(100% - 50px);
}
#playlist-editor-container {
height: 100%;
}
.loading-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
font-size: 4rem;
}
.editor-side-bar nav.opened {
z-index: $playlist-editor-z-index-select-hover !important;
}
}
// Editor view mode
#layout-editor.view-mode {
.widgetDelete {
display: none !important;
}
}
// Bootbox custom dialogs
.second-dialog {
z-index: $bootbox-second-dialog-z-index;
&+.in.modal-backdrop {
z-index: calc(#{$bootbox-second-dialog-z-index} - 100);
}
&~.select2-container.select2-container--open {
z-index: calc(#{$bootbox-second-dialog-z-index} + 100);
}
}
.inner-modal {
z-index: calc(#{$bootbox-second-dialog-z-index} + 1);
}
.vakata-context {
z-index: calc(#{$bootbox-second-dialog-z-index} + 2);
}
.playlist-widget-preview {
z-index: $playlist-editor-z-index-background;
&.tooltip.right .tooltip-arrow {
border-right-color: $xibo-color-secondary;
}
.tooltip-inner-image {
min-height: 50px;
padding: 4px;
background-color: $xibo-color-secondary;
border-radius: 4px;
img {
height: 60px;
max-width: 200px;
}
}
}
.playlist-editor-inline-container {
padding: 16px;
#playlist-timeline {
position: relative;
.loading-container {
height: calc(100vh - 140px);
}
}
#playlist-editor.multi-select #playlist-timeline {
position: initial;
}
}
#playlist-editor {
@include border-radius(4px);
width: 100%;
height: 100%;
background-color: $xibo-color-neutral-0;
border: 2px solid $playlist-editor-timeline-fg-color;
margin-bottom: 10px;
position: relative;
/* width */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/* Track */
::-webkit-scrollbar-track {
background: $xibo-color-primary-l5;
@include border-radius(6px);
}
/* Handle */
::-webkit-scrollbar-thumb {
background: $xibo-color-primary-d60;
@include border-radius(4px);
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: lighten($xibo-color-primary-d60, 20%);
}
::-webkit-scrollbar-corner {
@include box-shadow(inset 0 0 5px $xibo-color-neutral-700);
}
#timeline-container {
padding: 5px;
}
.properties-panel-playlist-editor {
position: fixed;
top: 0;
right: 0;
}
.playlist-editor-container {
height: 100%;
}
#timeline-overlay-container {
display: none;
padding: 5px 0;
position: absolute !important;
width: 100%;
min-height: 100%;
top: 0;
left: 0;
opacity: 0.6;
z-index: 2;
.timeline-overlay-step {
position: relative;
z-index: 2;
height: $timeline-step-height;
background: darken($xibo-color-primary-l30, 10%);
margin-top: calc($timeline-step-height / -2);
margin-bottom: calc($timeline-step-height / 2);
cursor: pointer;
&:hover, &.ui-droppable-hover, &.ui-droppable-active:hover {
background: $xibo-color-primary-l30;
@include box-shadow(0px 0px 3px 1px $xibo-color-primary-l5);
}
}
.timeline-overlay-dummy {
height: 32px;
margin-top: -$timeline-step-height;
}
}
.left-margin {
height: calc(100% - 38px);
position: absolute;
background-color: $xibo-color-neutral-300;
width: $timeline-left-margin-width;
}
.editor-body {
height: calc(100% - $bottom-bar-height);
position: relative;
overflow: auto;
.time-grid {
position: absolute;
color: $xibo-color-neutral-700;
width: 100%;
display: flex;
flex-direction: column;
gap: 22px;
top: 4px;
.time-grid-step {
height: 2px;
min-height: 2px;
position: relative;
background-color: $xibo-color-neutral-500;
left: 90px;
width: calc(100% - 90px);
}
.time-grid-step-with-value {
left: 30px;
width: calc(100% - 30px);
.step-value {
position: relative;
color: $xibo-color-neutral-700;
top: 0;
left: 0;
}
}
&::after {
content: ' ';
position: absolute;
width: calc(100% - $left-bar-width);
height: 100%;
left: $left-bar-width;
top: 0;
background-color: $xibo-color-neutral-0;
opacity: 0.7;
}
}
#playlist-timeline {
margin-left: $timeline-left-margin-width;
border: 0;
@include set-transparent-color(background-color, $xibo-color-neutral-0, 0.65);
z-index: 1;
@include border-radius(3px);
.playlist-widget {
@include border-radius(2px);
background-color: $xibo-color-primary-l10;
outline: 1px solid $xibo-color-primary-d60;
color: $xibo-color-secondary;
width: 100%;
padding: 6px 6px 6px 0;
display: flex;
justify-content: space-between;
gap: 6px;
position: relative;
.playlist-widget-left-area {
flex: 1;
display: flex;
flex-direction: column;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.widgetLabel {
flex: 0;
color: $playlist-editor-main-background-color;
flex-basis: 48px;
margin: -6px 0;
text-align: center;
float: left;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 6px 0;
.widgetDuration {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding: 0 4px;
}
.widgetSubType {
font-size: 1.2rem;
}
}
.widgetName {
flex: 1;
font-weight: bold;
color: $playlist-editor-widget-fg-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widgetProperties {
flex: 1;
font-size: 1.25rem;
display: flex;
align-items: flex-end;
}
.widgetPreview {
flex-basis: 100px;
img {
width: 100%;
height: 100%;
@include border-radius(2px);
object-fit: cover;
}
}
.widgetDelete {
display: none;
cursor: pointer;
position: absolute;
top: 0;
right: 0;
width: 25px;
height: 100%;
color: $xibo-color-neutral-100;
background-color: $xibo-color-semantic-error;
opacity: 0.7;
i {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
i.editProperty {
cursor: pointer;
padding: 4px;
border-radius: 4px;
width: 2rem;
text-align: center;
color: $playlist-editor-widget-fg-color;
&:hover {
color: darken($playlist-editor-widget-fg-color, 20%);
@include set-transparent-color(background-color, $xibo-color-neutral-0, 0.2);
}
}
&:not(.editable) {
@include box-shadow(inset 0 0 4px 2px $xibo-color-neutral-700);
}
&.selectable {
@include user-select-none();
&:hover {
background: $playlist-editor-widget-hover-bg-color;
}
}
&.selected {
background: $playlist-editor-widget-selected-color;
}
&.invalid-widget {
@include box-shadow(inset 0px 0px 10px 3px $xibo-color-semantic-error);
}
&:hover {
.widgetDelete {
display: block;
&:hover {
opacity: 0.9;
}
}
}
&.minimal-widget {
padding: 0;
.widgetName {
font-weight: normal;
}
.widgetProperties, .widgetDuration, .widgetPreview {
display: none;
}
.widgetLabel {
padding: 0;
margin: 0;
}
.widgetSubType {
font-size: 0.8rem;
padding: 2px;
}
}
}
&.ui-droppable-active {
@include z-index-set($playlist-editor-z-index-select);
min-height: 100%;
#timeline-container {
#timeline-overlay-container {
display: block;
background: darken($xibo-color-primary-l60, 15%) !important;
}
}
}
&.ui-droppable-hover, &.ui-droppable-active:hover {
#timeline-container {
#timeline-overlay-container {
background: darken($xibo-color-primary-l60, 10%) !important;
}
}
}
}
}
.editor-footer {
height: $bottom-bar-height;
display: flex;
.footer-controls {
background-color: $xibo-color-primary-d60;
min-width: $left-bar-width;
height: 100%;
display: flex;
justify-content: space-evenly;
.btn {
color: $xibo-color-neutral-100;
width: 25%;
padding: 0;
border-radius: 0;
border: none;
flex: 1;
i {
font-size: 14px;
}
&:hover {
color: $xibo-color-neutral-0;
background-color: $xibo-color-secondary;
}
&:focus {
box-shadow: none;
}
}
.btn-scale {
display: none;
}
}
.footer-info {
color: $xibo-color-neutral-100;
background-color: $xibo-color-primary;
flex: 1;
width: calc(100% - 140px);
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.selected-info {
color: $xibo-color-neutral-0;
max-width: calc(100% - 80px);
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
.fa-arrow-right {
margin: 0 12px;
}
.playlist-info-block {
max-width: 50%;
&:first-child {
margin-right: 8px;
}
i {
margin-right: 4px;
}
}
.label-name {
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.footer-actions {
font-size: 0;
button {
color: $xibo-color-neutral-100;
height: $bottom-bar-height;
border-radius: 0;
&:hover:not(.inactive) {
color: $xibo-color-neutral-0;
background: darken($xibo-color-primary, 10%);
}
&:focus {
box-shadow: none;
}
i {
margin: 0 auto;
}
&.multiselect-active {
background-color: $xibo-color-accent !important;
}
&.inactive {
opacity: 0.6;
cursor: auto;
}
&[data-action="remove-widget"] {
background-color: $xibo-color-semantic-error !important;
&:hover:not(.inactive) {
background-color: darken($xibo-color-semantic-error, 10%) !important;
}
}
&.hide-on-multi-select {
display: inline-block;
}
&.show-on-multi-select {
display: none;
}
}
}
}
}
.playlist-timeline-container {
z-index: auto;
}
&.multi-select {
.editor-footer {
.footer-info {
position: relative;
z-index: $playlist-editor-z-index-select;
}
.footer-actions {
.hide-on-multi-select {
display: none !important;
}
.show-on-multi-select {
display: inline-block !important;
}
}
}
.editor-side-bar nav.opened {
z-index: calc($playlist-editor-z-index-select - 2) !important;
}
#timeline-container {
position: relative;
.playlist-widget {
position: relative;
z-index: $playlist-editor-z-index-select;
.widgetDelete,
.widgetProperties {
display: none !important;
}
}
}
}
#playlist-properties-panel {
background: $xibo-color-primary-l5;
padding: 0;
@include border-radius(4px);
border: 2px solid $playlist-editor-widget-selected-color;
.form-container form {
padding-top: 10px;
}
/* Select2 width fix */
.select2-container {
width: auto !important;
}
/* Hide layout designer only messages */
.layout-designer-message {
display: none;
}
}
/* Multi Select */
&.multi-select {
.playlist-widget {
&:hover {
background: $playlist-editor-widget-multi-selected-hover-color !important;
}
&.multi-selected {
background: $playlist-editor-widget-multi-selected-color !important;
}
}
}
.custom-overlay {
z-index: $playlist-editor-z-index-overlay;
}
&.timeline-scaled {
.footer-controls {
.btn-scale-control {
background-color: $xibo-color-neutral-100;
color: $xibo-color-primary-d60;
&:hover {
color: lighten($xibo-color-primary-d60, 20%);
background-color: $xibo-color-neutral-0;
}
}
.btn-scale {
display: block;
}
}
}
&.external-playlist-message-on {
height: calc(100% - 46px);
}
}
.external-playlist-message-container {
background-color: lighten($xibo-color-semantic-warning, 10%);
border: 2px solid $xibo-color-semantic-warning;
font-weight: bold;
height: 50px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
border-radius: 4px 4px 0 0;
margin-bottom: -4px;
justify-content: center;
}

1357
ui/src/style/toolbar.scss Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More