init commit
40
ui/bundle_code_editor.js
Normal 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
@@ -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');
|
||||
|
||||
40
ui/bundle_editor_common.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
# /ui/src/assets
|
||||
This folder contains assets that are copied by webpack to the build /dist folder.
|
||||
BIN
ui/src/assets/map-marker-green-check-2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
ui/src/assets/map-marker-green-check.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
ui/src/assets/map-marker-green-cross-2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
ui/src/assets/map-marker-green-cross.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
ui/src/assets/map-marker-red-check-2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
ui/src/assets/map-marker-red-check.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
ui/src/assets/map-marker-red-cross-2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
ui/src/assets/map-marker-red-cross.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
ui/src/assets/map-marker-yellow-check-2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
ui/src/assets/map-marker-yellow-check.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
ui/src/assets/map-marker-yellow-cross-2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
ui/src/assets/map-marker-yellow-cross.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
ui/src/assets/marker-icon-2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
ui/src/assets/marker-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
ui/src/assets/marker-shadow.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
ui/src/assets/players/android.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
ui/src/assets/players/chromeos.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
ui/src/assets/players/linux.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
ui/src/assets/players/tizen.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
ui/src/assets/players/webos.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ui/src/assets/players/windows.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
455
ui/src/campaign-builder/main.js
Normal 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',
|
||||
'© <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
@@ -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
453
ui/src/core/help-pane.js
Normal 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
@@ -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
3134
ui/src/core/xibo-cms.js
Normal file
1511
ui/src/core/xibo-datatables.js
Normal file
243
ui/src/editor-core/bottombar.js
Normal 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>: ' + 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;
|
||||
48
ui/src/editor-core/change.js
Normal 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;
|
||||
302
ui/src/editor-core/common.js
Normal 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();
|
||||
},
|
||||
};
|
||||
352
ui/src/editor-core/element-group.js
Normal 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;
|
||||
326
ui/src/editor-core/element.js
Normal 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;
|
||||
571
ui/src/editor-core/history-manager.js
Normal 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;
|
||||
1142
ui/src/editor-core/layer-manager.js
Normal file
3476
ui/src/editor-core/properties-panel.js
Normal file
3760
ui/src/editor-core/toolbar.js
Normal file
454
ui/src/editor-core/topbar.js
Normal 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
35
ui/src/helpers/array.js
Normal 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([]);
|
||||
97
ui/src/helpers/date-format-helper.js
Normal 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();
|
||||
2559
ui/src/helpers/form-helpers.js
Normal file
3
ui/src/helpers/handlebars/arr.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = function(...args) {
|
||||
return Array.from(args).slice(0, arguments.length - 1);
|
||||
};
|
||||
3
ui/src/helpers/handlebars/arrMerge.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = function(array1, array2) {
|
||||
return array1.concat(array2);
|
||||
};
|
||||
7
ui/src/helpers/handlebars/concat.js
Normal 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('');
|
||||
};
|
||||
7
ui/src/helpers/handlebars/eq.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function(v1, v2, opts) {
|
||||
if (v1 === v2) {
|
||||
return opts.fn(this);
|
||||
} else {
|
||||
return opts.inverse(this);
|
||||
}
|
||||
};
|
||||
19
ui/src/helpers/handlebars/getOption.js
Normal 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;
|
||||
};
|
||||
7
ui/src/helpers/handlebars/gt.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function(v1, v2, opts) {
|
||||
if (v1 > v2) {
|
||||
return opts.fn(this);
|
||||
} else {
|
||||
return opts.inverse(this);
|
||||
}
|
||||
};
|
||||
26
ui/src/helpers/handlebars/ifCond.js
Normal 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);
|
||||
}
|
||||
};
|
||||
7
ui/src/helpers/handlebars/neq.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function(v1, v2, opts) {
|
||||
if (v1 !== v2) {
|
||||
return opts.fn(this);
|
||||
} else {
|
||||
return opts.inverse(this);
|
||||
}
|
||||
};
|
||||
3
ui/src/helpers/handlebars/number.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = function(str) {
|
||||
return Number(str);
|
||||
};
|
||||
3
ui/src/helpers/handlebars/obj.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = function({hash}) {
|
||||
return hash;
|
||||
};
|
||||
8
ui/src/helpers/handlebars/or.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = function(v1, v2, opts) {
|
||||
if (v1 || v2) {
|
||||
return opts.fn(this);
|
||||
} else {
|
||||
return opts.inverse(this);
|
||||
}
|
||||
};
|
||||
|
||||
7
ui/src/helpers/handlebars/render.js
Normal 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);
|
||||
};
|
||||
7
ui/src/helpers/handlebars/replace.js
Normal 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);
|
||||
};
|
||||
3
ui/src/helpers/handlebars/set.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = function(varName, varValue, opts) {
|
||||
opts.data.root[varName] = varValue;
|
||||
};
|
||||
362
ui/src/helpers/player-helper.js
Normal 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();
|
||||
23
ui/src/helpers/transformer.js
Normal 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();
|
||||
209
ui/src/layout-editor/action-manager.js
Normal 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;
|
||||
483
ui/src/layout-editor/canvas.js
Normal 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;
|
||||
1255
ui/src/layout-editor/layout.js
Normal file
6021
ui/src/layout-editor/main.js
Normal file
300
ui/src/layout-editor/region.js
Normal 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;
|
||||
170
ui/src/layout-editor/template-manager.js
Normal 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;
|
||||
5770
ui/src/layout-editor/viewer.js
Normal file
455
ui/src/pages/campaign/campaign-page.js
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
123
ui/src/pages/developer-template/developer-template-page.js
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
1525
ui/src/pages/display/display-page.js
Normal file
3
ui/src/pages/display/display-page.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
#settings-from-profile tr.row-fluid {
|
||||
height: 50px;
|
||||
}
|
||||
536
ui/src/pages/schedule/schedule-page.js
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
169
ui/src/pages/welcome/welcome-page.js
Normal 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');
|
||||
});
|
||||
});
|
||||
518
ui/src/pages/welcome/welcome-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1477
ui/src/playlist-editor/main.js
Normal file
427
ui/src/playlist-editor/playlist-timeline.js
Normal 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;
|
||||
495
ui/src/playlist-editor/playlist.js
Normal 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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
ui/src/style/campaign-builder.scss
Normal 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
@@ -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
73
ui/src/style/header-center-logo.scss
Normal 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
@@ -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);
|
||||
}
|
||||
}
|
||||
2489
ui/src/style/layout-editor.scss
Normal file
55
ui/src/style/mixins.scss
Normal 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;
|
||||
}
|
||||
858
ui/src/style/playlist-editor.scss
Normal 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;
|
||||
}
|
||||