Browse Source

UI: Migrate to latest webpack version 4.36.1

pull/1876/head
Igor Kulikov 7 years ago
parent
commit
99c99dcfe3
  1. 4
      ui/.babelrc
  2. 22346
      ui/package-lock.json
  3. 80
      ui/package.json
  4. 2
      ui/pom.xml
  5. 4
      ui/src/app/common/utils.service.js
  6. 2
      ui/src/app/components/details-sidenav.directive.js
  7. 4
      ui/src/app/components/kv-map.directive.js
  8. 4
      ui/src/app/components/tb-event-directives.js
  9. 2
      ui/src/app/dashboard/states/entity-state-controller.js
  10. 2
      ui/src/app/ie.support.js
  11. 2
      ui/src/app/import-export/import-export.service.js
  12. 2
      ui/src/app/widget/lib/date-range-navigator/date-range-navigator.js
  13. 8
      ui/src/app/widget/lib/google-map.js
  14. 2
      ui/src/app/widget/lib/map-widget.js
  15. 2
      ui/src/app/widget/lib/tripAnimation/trip-animation-widget.js
  16. 6
      ui/src/app/widget/lib/widget-utils.js
  17. 142
      ui/webpack.config.dev.js
  18. 151
      ui/webpack.config.prod.js

4
ui/.babelrc

@ -1,7 +1,7 @@
{
"presets": [
"react",
"es2015"
"@babel/preset-react",
"@babel/preset-env"
],
"plugins": [
"react-hot-loader/babel"

22346
ui/package-lock.json

File diff suppressed because it is too large

80
ui/package.json

@ -95,53 +95,57 @@
"v-accordion": "^1.6.0"
},
"devDependencies": {
"babel-cli": "^6.18.0",
"babel-core": "^6.14.0",
"babel-eslint": "^6.1.2",
"babel-loader": "^6.2.5",
"babel-preset-es2015": "^6.14.0",
"babel-preset-react": "^6.16.0",
"compression-webpack-plugin": "^1.1.11",
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/node": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"babel-eslint": "^10.0.2",
"babel-loader": "^8.0.6",
"browserslist": "^4.6.6",
"compression-webpack-plugin": "^3.0.0",
"connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^3.0.1",
"cross-env": "^3.2.4",
"css-loader": "^0.25.0",
"copy-webpack-plugin": "^5.0.3",
"cross-env": "^5.2.0",
"css-loader": "^3.1.0",
"directory-tree": "^2.2.3",
"eslint": "^3.4.0",
"eslint": "^6.0.1",
"eslint-config-angular": "^0.5.0",
"eslint-loader": "^1.5.0",
"eslint-plugin-angular": "^1.3.1",
"eslint-plugin-import": "^1.14.0",
"extract-text-webpack-plugin": "^1.0.1",
"file-loader": "^0.9.0",
"happypack": "^5.0.1",
"html-loader": "^0.4.3",
"html-minifier": "^3.2.2",
"html-minifier-loader": "^1.3.4",
"html-webpack-plugin": "^2.30.1",
"img-loader": "^1.3.1",
"eslint-loader": "^2.2.1",
"eslint-plugin-angular": "^4.0.1",
"eslint-plugin-import": "^2.18.1",
"file-loader": "^4.1.0",
"html-loader": "^0.5.5",
"html-minifier": "^4.0.0",
"html-minifier-loader": "^1.4.1",
"html-webpack-plugin": "^3.2.0",
"imagemin": "^6.0.0",
"img-loader": "^3.0.1",
"jsonminify": "^0.4.1",
"less": "^2.7.1",
"less-loader": "^2.2.3",
"ng-annotate-loader": "^0.1.1",
"ngtemplate-loader": "^1.3.1",
"less": "^3.9.0",
"less-loader": "^5.0.0",
"mini-css-extract-plugin": "^0.8.0",
"ng-annotate-loader": "^0.6.1",
"ng-annotate-patched": "^1.10.0",
"ngtemplate-loader": "^2.0.1",
"node-sass": "^4.12.0",
"postcss-loader": "^3.0.0",
"raw-loader": "^0.5.1",
"react-hot-loader": "^3.0.0-beta.6",
"sass-loader": "^4.0.2",
"style-loader": "^0.13.1",
"stylelint": "^9.10.1",
"raw-loader": "^3.1.0",
"react-hot-loader": "^4.12.8",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"stylelint": "^8.4.0",
"stylelint-config-recommended-scss": "^3.3.0",
"stylelint-config-standard": "^18.3.0",
"stylelint-order": "^1.0.0",
"stylelint-scss": "^3.8.0",
"stylelint-order": "^3.0.1",
"stylelint-scss": "^3.9.2",
"stylelint-webpack-plugin": "^0.10.5",
"uglifyjs-webpack-plugin": "^1.3.0",
"url-loader": "^0.5.7",
"webpack": "^1.13.2",
"webpack-dev-middleware": "^1.6.1",
"webpack-dev-server": "^1.15.1",
"uglifyjs-webpack-plugin": "^2.1.3",
"url-loader": "^2.1.0",
"webpack": "^4.36.1",
"webpack-cli": "^3.3.6",
"webpack-dev-middleware": "^3.7.0",
"webpack-dev-server": "^3.7.2",
"webpack-hot-middleware": "^2.25.0",
"webpack-material-design-icons": "^0.1.0"
},

2
ui/pom.xml

@ -57,7 +57,7 @@
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>v6.9.1</nodeVersion>
<nodeVersion>v8.16.0</nodeVersion>
<npmVersion>6.4.1</npmVersion>
</configuration>
</execution>

4
ui/src/app/common/utils.service.js

@ -30,7 +30,7 @@ export default angular.module('thingsboard.utils', [thingsboardTypes])
.factory('utils', Utils)
.name;
const varsRegex = /\$\{([^\}]*)\}/g;
const varsRegex = /\$\{([^}]*)\}/g;
/*@ngInject*/
function Utils($mdColorPalette, $rootScope, $window, $translate, $q, $timeout, types) {
@ -325,7 +325,7 @@ function Utils($mdColorPalette, $rootScope, $window, $translate, $q, $timeout, t
function isDescriptorSchemaNotEmpty(descriptor) {
if (descriptor && descriptor.schema && descriptor.schema.properties) {
for(var prop in descriptor.schema.properties) {
if (descriptor.schema.properties.hasOwnProperty(prop)) {
if (Object.prototype.hasOwnProperty.call(descriptor.schema.properties, prop)) {
return true;
}
}

2
ui/src/app/components/details-sidenav.directive.js

@ -45,7 +45,7 @@ function DetailsSidenav($timeout, $mdUtil, $q, $animate) {
var backdrop;
var previousContainerStyles;
if (attrs.hasOwnProperty('tbEnableBackdrop')) {
if (Object.prototype.hasOwnProperty.call(attrs,'tbEnableBackdrop')) {
backdrop = $mdUtil.createBackdrop(scope, "md-sidenav-backdrop md-opaque ng-enter");
element.on('$destroy', function() {
backdrop && backdrop.remove();

4
ui/src/app/components/kv-map.directive.js

@ -59,7 +59,7 @@ function KeyValMapController($scope, $mdUtil) {
vm.kvList.length = 0;
if (vm.keyValMap) {
for (var property in vm.keyValMap) {
if (vm.keyValMap.hasOwnProperty(property)) {
if (Object.prototype.hasOwnProperty.call(vm.keyValMap, property)) {
vm.kvList.push(
{
key: property + '',
@ -80,7 +80,7 @@ function KeyValMapController($scope, $mdUtil) {
return;
}
for (var property in vm.keyValMap) {
if (vm.keyValMap.hasOwnProperty(property)) {
if (Object.prototype.hasOwnProperty.call(vm.keyValMap, property)) {
delete vm.keyValMap[property];
}
}

4
ui/src/app/components/tb-event-directives.js

@ -13,9 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g;
const SPECIAL_CHARS_REGEXP = /([:\-_]+(.))/g;
const MOZ_HACK_REGEXP = /^moz([A-Z])/;
const PREFIX_REGEXP = /^((?:x|data)[\:\-_])/i;
const PREFIX_REGEXP = /^((?:x|data)[:\-_])/i;
var tbEventDirectives = {};

2
ui/src/app/dashboard/states/entity-state-controller.js

@ -346,7 +346,7 @@ export default function EntityStateController($scope, $timeout, $location, $stat
function isEmpty(map) {
for(var key in map) {
return !map.hasOwnProperty(key);
return !Object.prototype.hasOwnProperty.call(map, key);
}
return true;
}

2
ui/src/app/ie.support.js

@ -69,7 +69,7 @@
(function (arr) {
arr.forEach(function (item) {
if (item.hasOwnProperty('remove')) {
if (Object.prototype.hasOwnProperty.call(item, 'remove')) {
return;
}
Object.defineProperty(item, 'remove', {

2
ui/src/app/import-export/import-export.service.js

@ -811,7 +811,7 @@ export default function ImportExport($log, $translate, $q, $mdDialog, $document,
result.rows = [];
for (let r in csvrows) {
if (csvrows.hasOwnProperty(r)) {
if (Object.prototype.hasOwnProperty.call(csvrows, r)) {
let row = csvrows[r];
if (row.length === 0)

2
ui/src/app/widget/lib/date-range-navigator/date-range-navigator.js

@ -268,7 +268,7 @@ function DateRangeNavigatorWidgetController($scope, $window, $filter) {
let interval = $scope.advancedModel.dateEnd.getTime() - $scope.advancedModel.dateStart.getTime();
for (let i in $scope.datesMap) {
if ($scope.datesMap.hasOwnProperty(i)) {
if (Object.prototype.hasOwnProperty.call($scope.datesMap, i)) {
if ($scope.datesMap[i].ts === interval || $scope.datesMap[i].ts === interval + 1 || $scope.datesMap[i].ts === interval - 1) {
$scope.selectedDateInterval = $scope.datesMap[i].ts;
$scope.customInterval = false;

8
ui/src/app/widget/lib/google-map.js

@ -325,7 +325,7 @@ export default class TbGoogleMap {
createPolygon(latLangs, settings, location, onClickListener, markerArgs) {
let polygon = new google.maps.Polygon({
let polygon = new google.maps.Polygon({ // eslint-disable-line no-undef
map: this.map,
paths: latLangs,
strokeColor: settings.polygonStrokeColor,
@ -337,7 +337,7 @@ export default class TbGoogleMap {
//initialize-tooltip
let popup = new google.maps.InfoWindow({
let popup = new google.maps.InfoWindow({ // eslint-disable-line no-undef
content: ''
});
if (!this.tooltips) this.tooltips = [];
@ -349,7 +349,7 @@ export default class TbGoogleMap {
});
let map = this;
if (onClickListener) {
google.maps.event.addListener(polygon, 'click', function (event) {
google.maps.event.addListener(polygon, 'click', function (event) { // eslint-disable-line no-undef
if (settings.displayTooltip ) {
if (settings.autocloseTooltip) {
map.tooltips.forEach((tooltip) => {
@ -357,7 +357,7 @@ export default class TbGoogleMap {
});
}
if (!polygon.anchor) {
polygon.anchor = new google.maps.MVCObject();
polygon.anchor = new google.maps.MVCObject(); // eslint-disable-line no-undef
}
polygon.anchor.set("position", event.latLng);
popup.open(this.map, polygon.anchor);

2
ui/src/app/widget/lib/map-widget.js

@ -81,7 +81,7 @@ export default class TbMapWidget {
this.polylines = [];
}
this.locationsSettings = [];
this.varsRegex = /\$\{([^\}]*)\}/g;
this.varsRegex = /\$\{([^}]*)\}/g;
var settings = ctx.settings;

2
ui/src/app/widget/lib/tripAnimation/trip-animation-widget.js

@ -679,7 +679,7 @@ function tripAnimationController($document, $scope, $log, $http, $timeout, $filt
function fillPattern(pattern, replaceInfo, currentNormalizedValue) {
let text = angular.copy(pattern);
let reg = /\$\{([^\}]*)\}/g;
let reg = /\$\{([^}]*)\}/g;
if (replaceInfo) {
for (let v = 0; v < replaceInfo.variables.length; v++) {
let variableInfo = replaceInfo.variables[v];

6
ui/src/app/widget/lib/widget-utils.js

@ -13,10 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const varsRegex = /\$\{([^\}]*)\}/g;
const varsRegex = /\$\{([^}]*)\}/g;
const linkActionRegex = /\<link-act name=['"]([^['"]*)['"]\>([^\<]*)\<\/link-act\>/g;
const buttonActionRegex = /\<button-act name=['"]([^['"]*)['"]\>([^\<]*)\<\/button-act\>/g;
const linkActionRegex = /<link-act name=['"]([^['"]*)['"]>([^<]*)<\/link-act>/g;
const buttonActionRegex = /<button-act name=['"]([^['"]*)['"]>([^<]*)<\/button-act>/g;
export function processPattern(pattern, datasources, dsIndex) {
var match = varsRegex.exec(pattern);

142
ui/webpack.config.dev.js

@ -16,7 +16,7 @@
/* eslint-disable */
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const StyleLintPlugin = require('stylelint-webpack-plugin');
@ -25,8 +25,6 @@ const path = require('path');
const dirTree = require('directory-tree');
const jsonminify = require("jsonminify");
const HappyPack = require('happypack');
const PUBLIC_RESOURCE_PATH = '/';
var langs = [];
@ -36,12 +34,10 @@ dirTree('./src/app/locale/', {extensions:/\.json$/}, (item) => {
langs.push(item.name.slice(item.name.lastIndexOf('-') + 1, -5));
});
var happyThreadPool = HappyPack.ThreadPool({ size: 3 });
/* devtool: 'cheap-module-eval-source-map', */
module.exports = {
mode: 'development',
devtool: 'source-map',
entry: [
'./src/app/app.js',
@ -84,34 +80,14 @@ module.exports = {
inject: 'body',
}),
new StyleLintPlugin(),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.NoErrorsPlugin(),
new ExtractTextPlugin('style.[contentHash].css', {
allChunks: true,
new MiniCssExtractPlugin({
filename: 'style.[contentHash].css'
}),
new webpack.DefinePlugin({
THINGSBOARD_VERSION: JSON.stringify(require('./package.json').version),
'__DEVTOOLS__': false,
'process.env': {
NODE_ENV: JSON.stringify('development'),
},
PUBLIC_PATH: JSON.stringify(PUBLIC_RESOURCE_PATH),
SUPPORTED_LANGS: JSON.stringify(langs)
}),
new HappyPack({
threadPool: happyThreadPool,
id: 'cached-babel',
loaders: ["babel-loader?cacheDirectory=true"]
}),
new HappyPack({
threadPool: happyThreadPool,
id: 'eslint',
loaders: ["eslint-loader?{parser: 'babel-eslint'}"]
}),
new HappyPack({
threadPool: happyThreadPool,
id: 'ng-annotate-and-cached-babel-loader',
loaders: ['ng-annotate', 'babel-loader?cacheDirectory=true']
})
],
node: {
@ -119,59 +95,131 @@ module.exports = {
fs: "empty"
},
module: {
loaders: [
rules: [
{
test: /\.jsx$/,
loader: 'happypack/loader?id=cached-babel',
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true
}
}
],
exclude: /node_modules/,
include: __dirname,
},
{
test: /\.js$/,
loaders: ['happypack/loader?id=ng-annotate-and-cached-babel-loader'],
use: [
{
loader: 'ng-annotate-loader',
options: {
ngAnnotate: 'ng-annotate-patched',
es6: true,
explicitOnly: false
}
},
{
loader: 'babel-loader',
options: {
cacheDirectory: true
}
}
],
exclude: /node_modules/,
include: __dirname,
},
{
test: /\.js$/,
loader: 'happypack/loader?id=eslint',
use: [
{
loader: 'eslint-loader',
options: {
parser: 'babel-eslint'
}
}
],
exclude: /node_modules|vendor/,
include: __dirname,
},
{
test: /\.css$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader'),
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
},
{
test: /\.scss$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!sass-loader'),
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
},
{
test: /\.less$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!less-loader'),
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'less-loader'
]
},
{
test: /\.tpl\.html$/,
loader: 'ngtemplate?relativeTo=' + (path.resolve(__dirname, './src/app')) + '/!html!html-minifier-loader'
use: [
{
loader: 'ngtemplate-loader',
options: {
relativeTo: path.resolve(__dirname, './src/app')
}
},
{
loader: 'html-loader'
},
{
loader: 'html-minifier-loader',
options: {
caseSensitive: true,
removeComments: true,
collapseWhitespace: false,
preventAttributesEscaping: true,
removeEmptyAttributes: false
}
}
]
},
{
test: /\.(svg)(\?v=[0-9]+\.[0-9]+\.[0-9]+)?$/,
loader: 'url?limit=8192'
use: [
{
loader: 'url-loader',
options: {
limit: 8192
}
}
]
},
{
test: /\.(png|jpe?g|gif|woff|woff2|ttf|otf|eot|ico)(\?v=[0-9]+\.[0-9]+\.[0-9]+)?$/,
loaders: [
'url?limit=8192',
'img?minimize'
use: [
{
loader: 'url-loader',
options: {
limit: 8192
}
},
{
loader: 'img-loader',
options: {
minimize: true
}
}
]
}
],
},
'html-minifier-loader': {
caseSensitive: true,
removeComments: true,
collapseWhitespace: false,
preventAttributesEscaping: true,
removeEmptyAttributes: false
}
};

151
ui/webpack.config.prod.js

@ -16,15 +16,14 @@
/* eslint-disable */
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const webpack = require('webpack');
const path = require('path');
const dirTree = require('directory-tree');
const jsonminify = require("jsonminify");
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const HappyPack = require('happypack');
const PUBLIC_RESOURCE_PATH = '/static/';
@ -35,9 +34,8 @@ dirTree('./src/app/locale/', {extensions:/\.json$/}, (item) => {
langs.push(item.name.slice(item.name.lastIndexOf('-') + 1, -5));
});
var happyThreadPool = HappyPack.ThreadPool({ size: 3 });
module.exports = {
mode: 'production',
devtool: 'source-map',
entry: [
'./src/app/app.js',
@ -77,47 +75,21 @@ module.exports = {
title: 'ThingsBoard',
inject: 'body',
}),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.NoErrorsPlugin(),
new webpack.optimize.DedupePlugin(),
new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.[hash].js'),
new ExtractTextPlugin('style.[contentHash].css', {
allChunks: true,
new MiniCssExtractPlugin({
filename: 'style.[contentHash].css'
}),
new webpack.DefinePlugin({
THINGSBOARD_VERSION: JSON.stringify(require('./package.json').version),
'__DEVTOOLS__': false,
'process.env': {
NODE_ENV: JSON.stringify('production'),
},
PUBLIC_PATH: PUBLIC_RESOURCE_PATH,
SUPPORTED_LANGS: JSON.stringify(langs)
}),
new CompressionPlugin({
asset: "[path].gz[query]",
filename: "[path].gz[query]",
algorithm: "gzip",
test: /\.js$|\.css$|\.svg$|\.ttf$|\.woff$|\.woff2$|\.eot$|\.json$/,
threshold: 10240,
minRatio: 0.8
}),
new UglifyJsPlugin({
cache: true,
parallel: true
}),
new HappyPack({
threadPool: happyThreadPool,
id: 'cached-babel',
loaders: ["babel-loader?cacheDirectory=true"]
}),
new HappyPack({
threadPool: happyThreadPool,
id: 'eslint',
loaders: ["eslint-loader?{parser: 'babel-eslint'}"]
}),
new HappyPack({
threadPool: happyThreadPool,
id: 'ng-annotate-and-cached-babel-loader',
loaders: ['ng-annotate', 'babel-loader?cacheDirectory=true']
})
],
node: {
@ -125,60 +97,131 @@ module.exports = {
fs: "empty"
},
module: {
loaders: [
rules: [
{
test: /\.jsx$/,
loader: 'happypack/loader?id=cached-babel',
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true
}
}
],
exclude: /node_modules/,
include: __dirname,
},
{
test: /\.js$/,
loaders: ['happypack/loader?id=ng-annotate-and-cached-babel-loader'],
use: [
{
loader: 'ng-annotate-loader',
options: {
ngAnnotate: 'ng-annotate-patched',
es6: true,
explicitOnly: false
}
},
{
loader: 'babel-loader',
options: {
cacheDirectory: true
}
}
],
exclude: /node_modules/,
include: __dirname,
},
{
test: /\.js$/,
loaders: ['happypack/loader?id=eslint'],
// loader: "eslint-loader?{parser: 'babel-eslint'}",
use: [
{
loader: 'eslint-loader',
options: {
parser: 'babel-eslint'
}
}
],
exclude: /node_modules|vendor/,
include: __dirname,
},
{
test: /\.css$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader'),
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
},
{
test: /\.scss$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!sass-loader'),
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
},
{
test: /\.less$/,
loader: ExtractTextPlugin.extract('style-loader', 'css-loader!postcss-loader!less-loader'),
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'less-loader'
]
},
{
test: /\.tpl\.html$/,
loader: 'ngtemplate?relativeTo=' + (path.resolve(__dirname, './src/app')) + '/!html!html-minifier-loader'
use: [
{
loader: 'ngtemplate-loader',
options: {
relativeTo: path.resolve(__dirname, './src/app')
}
},
{
loader: 'html-loader'
},
{
loader: 'html-minifier-loader',
options: {
caseSensitive: true,
removeComments: true,
collapseWhitespace: false,
preventAttributesEscaping: true,
removeEmptyAttributes: false
}
}
]
},
{
test: /\.(svg)(\?v=[0-9]+\.[0-9]+\.[0-9]+)?$/,
loader: 'url?limit=8192'
use: [
{
loader: 'url-loader',
options: {
limit: 8192
}
}
]
},
{
test: /\.(png|jpe?g|gif|woff|woff2|ttf|otf|eot|ico)(\?v=[0-9]+\.[0-9]+\.[0-9]+)?$/,
loaders: [
'url?limit=8192',
'img?minimize'
use: [
{
loader: 'url-loader',
options: {
limit: 8192
}
},
{
loader: 'img-loader',
options: {
minimize: true
}
}
]
}
],
},
'html-minifier-loader': {
caseSensitive: true,
removeComments: true,
collapseWhitespace: false,
preventAttributesEscaping: true,
removeEmptyAttributes: false
}
};

Loading…
Cancel
Save