mirror of
				https://github.com/scottlamb/moonfire-nvr.git
				synced 2025-10-29 15:55:01 -04:00 
			
		
		
		
	A little more UI refactor, cleanup, eslint more strict (#54)
* A little more UI refactor, cleanup, eslint more strict * Split out imports for jQuery components and put them where needed. * No longer do all of it in application module. * Prepares better for code splitting. * Split out video player dialog * Simplifies jquery-ui dependencies for code splitting * Simplifies code * Configure to generate more, but smaller bundles. * Setup some more strict eslint settings * Fix css to import rather than require * Change settings to correctly support tree shaking in production build Signed-off-by: Dolf Starreveld <dolf@starreveld.com> * Remove “old” code from TimeFormatter * Accidentally left behind due to overlapping PRs Signed-off-by: Dolf Starreveld <dolf@starreveld.com>
This commit is contained in:
		
							parent
							
								
									eaae640703
								
							
						
					
					
						commit
						f5aa0080bb
					
				| @ -3,7 +3,20 @@ | ||||
|         "ecmaVersion": 6, | ||||
|         "sourceType": "module" | ||||
|     }, | ||||
|     "env": { | ||||
|       "es6": true, | ||||
|       "browser": true, | ||||
|       "node": true | ||||
|     }, | ||||
|     "extends": "google", | ||||
|     "rules": { | ||||
|       "init-declarations": ["error", "always"], | ||||
|       "no-catch-shadow": ["error"], | ||||
|       "no-delete-var": ["error"], | ||||
|       "no-shadow": ["error", { "builtinGlobals": false, "hoist": "functions", "allow": [] }], | ||||
|       "no-shadow-restricted-names": ["error"], | ||||
|       "no-undef": ["error", {"typeof": true}], | ||||
|       "no-unused-vars": ["error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": false }], | ||||
|       "no-use-before-define": ["error", { "functions": true, "classes": true }] | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -21,6 +21,7 @@ | ||||
|     "babel-core": "^6.26.0", | ||||
|     "babel-loader": "^7.1.4", | ||||
|     "babel-minify-webpack-plugin": "^0.3.0", | ||||
|     "babel-plugin-transform-imports": "^1.5.0", | ||||
|     "babel-preset-env": "^1.6.1", | ||||
|     "clean-webpack-plugin": "^0.1.18", | ||||
|     "compression-webpack-plugin": "^1.1.10", | ||||
|  | ||||
| @ -34,31 +34,32 @@ | ||||
| // TODO: add error bar on fetch failure.
 | ||||
| // TODO: live updating.
 | ||||
| 
 | ||||
| import 'jquery-ui/themes/base/button.css'; | ||||
| import $ from 'jquery'; | ||||
| 
 | ||||
| // tooltip needs:
 | ||||
| // css.structure: ../../themes/base/core.css
 | ||||
| // css.structure: ../../themes/base/tooltip.css
 | ||||
| // css.theme: ../../themes/base/theme.css
 | ||||
| 
 | ||||
| import 'jquery-ui/themes/base/core.css'; | ||||
| import 'jquery-ui/themes/base/datepicker.css'; | ||||
| import 'jquery-ui/themes/base/dialog.css'; | ||||
| import 'jquery-ui/themes/base/resizable.css'; | ||||
| import 'jquery-ui/themes/base/theme.css'; | ||||
| import 'jquery-ui/themes/base/tooltip.css'; | ||||
| import 'jquery-ui/themes/base/theme.css'; | ||||
| 
 | ||||
| // This causes our custom css to be loaded after the above!
 | ||||
| require('./assets/index.css'); | ||||
| import './assets/index.css'; | ||||
| 
 | ||||
| import $ from 'jquery'; | ||||
| import 'jquery-ui/ui/widgets/datepicker'; | ||||
| import 'jquery-ui/ui/widgets/dialog'; | ||||
| // Get ui widgets themselves
 | ||||
| import 'jquery-ui/ui/widgets/tooltip'; | ||||
| 
 | ||||
| import Camera from './lib/models/Camera'; | ||||
| import CameraView from './lib/views/CameraView'; | ||||
| import CalendarView from './lib/views/CalendarView'; | ||||
| import VideoDialogView from './lib/views/VideoDialogView'; | ||||
| import NVRSettingsView from './lib/views/NVRSettingsView'; | ||||
| import CheckboxGroupView from './lib/views/CheckboxGroupView'; | ||||
| import RecordingFormatter from './lib/support/RecordingFormatter'; | ||||
| import TimeFormatter, { | ||||
|   TimeStamp90kFormatter, | ||||
| } from './lib/support/TimeFormatter'; | ||||
| import TimeFormatter from './lib/support/TimeFormatter'; | ||||
| import TimeStamp90kFormatter from './lib/support/TimeStamp90kFormatter'; | ||||
| import MoonfireAPI from './lib/MoonfireAPI'; | ||||
| 
 | ||||
| const api = new MoonfireAPI(); | ||||
| @ -130,27 +131,18 @@ function onSelectVideo(nvrSettingsView, camera, range, recording) { | ||||
|     trimmedRange, | ||||
|     nvrSettingsView.timeStampTrack | ||||
|   ); | ||||
|   const video = $('<video controls preload="auto" autoplay="true" />'); | ||||
|   const dialog = $('<div class="playdialog" />').append(video); | ||||
|   $('body').append(dialog); | ||||
| 
 | ||||
|   let [formattedStart, formattedEnd] = timeFormatter90k.formatSameDayShortened( | ||||
|   const [ | ||||
|     formattedStart, | ||||
|     formattedEnd, | ||||
|   ] = timeFormatter90k.formatSameDayShortened( | ||||
|     trimmedRange.startTime90k, | ||||
|     trimmedRange.endTime90k | ||||
|   ); | ||||
|   dialog.dialog({ | ||||
|     title: camera.shortName + ', ' + formattedStart + ' to ' + formattedEnd, | ||||
|     width: recording.videoSampleEntryWidth / 4, | ||||
|     close: () => { | ||||
|         const videoDOMElement = video[0]; | ||||
|         videoDOMElement.pause(); | ||||
|         videoDOMElement.src = ''; // Remove current source to stop loading
 | ||||
|         dialog.remove(); | ||||
|     }, | ||||
|   }); | ||||
|   // Now that dialog is up, set the src so video starts
 | ||||
|   console.log('Video url: ' + url); | ||||
|   video.attr('src', url); | ||||
|   const videoTitle = | ||||
|     camera.shortName + ', ' + formattedStart + ' to ' + formattedEnd; | ||||
|   new VideoDialogView() | ||||
|     .attach($('body')) | ||||
|     .play(videoTitle, recording.videoSampleEntryWidth / 4, url); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -193,16 +185,19 @@ function fetch(selectedRange, videoLength) { | ||||
|       cameraView.recordingsReq = null; | ||||
|     }); | ||||
|     r | ||||
|       .then(function(data, status, req) { | ||||
|       .then(function(data /* , status, req */) { | ||||
|         // Sort recordings in descending order.
 | ||||
|         data.recordings.sort(function(a, b) { | ||||
|           return b.startId - a.startId; | ||||
|         }); | ||||
|         console.log('Fetched results > updating recordings'); | ||||
|         console.log( | ||||
|           'Fetched results for "%s" > updating recordings', | ||||
|           cameraView.camera.shortName | ||||
|         ); | ||||
|         cameraView.recordingsJSON = data.recordings; | ||||
|       }) | ||||
|       .catch(function(data, status, err) { | ||||
|         console.log(url, ' load failed: ', status, ': ', err); | ||||
|         console.error(url, ' load failed: ', status, ': ', err); | ||||
|       }); | ||||
|   } | ||||
| } | ||||
| @ -295,11 +290,11 @@ export default class NVRApplication { | ||||
|       .request(api.nvrUrl(true)) | ||||
|       .done((data) => onReceivedCameras(data)) | ||||
|       .fail((req, status, err) => { | ||||
|         console.log('NVR load error: ', status, err); | ||||
|         console.error('NVR load error: ', status, err); | ||||
|         onReceivedCameras({cameras: []}); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.log('NVR load exception: ', e); | ||||
|         console.error('NVR load exception: ', e); | ||||
|         onReceivedCameras({cameras: []}); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
| @ -4,7 +4,8 @@ body { | ||||
| #nav { | ||||
|     float: left; | ||||
| } | ||||
| .ui-datepicker { | ||||
| 
 | ||||
| #datetime .ui-datepicker { | ||||
|     width: 100%; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -33,6 +33,7 @@ | ||||
| import NVRApplication from './NVRApplication'; | ||||
| 
 | ||||
| import $ from 'jquery'; | ||||
| import './favicon.ico'; | ||||
| 
 | ||||
| // On document load, start application
 | ||||
| $(function() { | ||||
|  | ||||
| @ -59,7 +59,7 @@ export default class MoonfireAPI { | ||||
|     url.hostname = host; | ||||
|     url.port = port; | ||||
|     console.log('API: ' + url.origin + url.pathname); | ||||
|     this._builder = new URLBuilder(url.origin + url.pathname); | ||||
|     this._builder = new URLBuilder(url.origin + url.pathname, relativeUrls); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -31,7 +31,7 @@ | ||||
| // along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| 
 | ||||
| import Time90kParser from '../support/Time90kParser'; | ||||
| import {TimeStamp90kFormatter} from '../support/TimeFormatter'; | ||||
| import TimeStamp90kFormatter from '../support/TimeStamp90kFormatter'; | ||||
| import Range90k from './Range90k'; | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -45,7 +45,7 @@ export default class Range { | ||||
|    */ | ||||
|   constructor(low, high) { | ||||
|     if (high < low) { | ||||
|       console.log('Warning range swap: ' + low + ' - ' + high); | ||||
|       console.warn('Warning range swap: ' + low + ' - ' + high); | ||||
|       [low, high] = [high, low]; | ||||
|     } | ||||
|     this.low = low; | ||||
| @ -65,7 +65,7 @@ export default class Range { | ||||
|    * Determine if value is inside the range. | ||||
|    * | ||||
|    * @param  {Number}  value Value to test | ||||
|    * @return {Boolean} | ||||
|    * @return {Boolean}  | ||||
|    */ | ||||
|   isInRange(value) { | ||||
|     return value >= this.low && value <= this.high; | ||||
|  | ||||
| @ -40,9 +40,10 @@ import Range from './Range'; | ||||
| let _range = new WeakMap(); | ||||
| 
 | ||||
| /** | ||||
|  * Subclass of Range to represent ranges over timestamps in 90k format. | ||||
|  * Class like Range to represent ranges over timestamps in 90k format. | ||||
|  * | ||||
|  * This mostly means added some getters with names that make more sense. | ||||
|  * A composed member of the Range class is use for the heavy lifting, while | ||||
|  * this class provides a different interface. | ||||
|  */ | ||||
| export default class Range90k { | ||||
|   /** | ||||
|  | ||||
| @ -32,7 +32,7 @@ | ||||
| 
 | ||||
| import moment from 'moment-timezone'; | ||||
| 
 | ||||
| export const internalTimeFormat = 'YYYY-MM-DDTHH:mm:ss:FFFFFZ'; | ||||
| 
 | ||||
| export const defaultTimeFormat = 'YYYY-MM-DD HH:mm:ss'; | ||||
| 
 | ||||
| /** | ||||
| @ -117,50 +117,3 @@ export default class TimeFormatter { | ||||
|     return moment.tz(ms, this._tz).format(format); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Specialized class similar to TimeFormatter but forcing a specific time format | ||||
|  * for internal usage purposes. | ||||
|  */ | ||||
| export class TimeStamp90kFormatter { | ||||
|   /** | ||||
|    * Construct from just a timezone specification. | ||||
|    * | ||||
|    * @param  {String} tz Timezone | ||||
|    */ | ||||
|   constructor(tz) { | ||||
|     this._formatter = new TimeFormatter(internalTimeFormat, tz); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Format a timestamp in 90k units using internal format. | ||||
|    * | ||||
|    * @param {Number} ts90k timestamp in 90,000ths of a second resolution | ||||
|    * @return {String}        Formatted timestamp | ||||
|    */ | ||||
|   formatTimeStamp90k(ts90k) { | ||||
|     return this._formatter.formatTimeStamp90k(ts90k); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Given two timestamp return formatted versions of both, where the second | ||||
|    * one may have been shortened if it falls on the same date as the first one. | ||||
|    * | ||||
|    * @param  {Number} ts1 First timestamp in 90k units | ||||
|    * @param  {Number} ts2 Secodn timestamp in 90k units | ||||
|    * @return {Array}     Array with two elements: [ ts1Formatted, ts2Formatted ] | ||||
|    */ | ||||
|   formatSameDayShortened(ts1, ts2) { | ||||
|     let ts1Formatted = this.formatTimeStamp90k(ts1); | ||||
|     let ts2Formatted = this.formatTimeStamp90k(ts2); | ||||
|     let timePos = this._formatter.formatStr.indexOf('T'); | ||||
|     if (timePos != -1) { | ||||
|       const datePortion = ts1Formatted.substr(0, timePos); | ||||
|       ts1Formatted = datePortion + ' ' + ts1Formatted.substr(timePos + 1); | ||||
|       if (ts2Formatted.startsWith(datePortion)) { | ||||
|         ts2Formatted = ts2Formatted.substr(timePos + 1); | ||||
|       } | ||||
|     } | ||||
|     return [ts1Formatted, ts2Formatted]; | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										83
									
								
								ui-src/lib/support/TimeStamp90kFormatter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								ui-src/lib/support/TimeStamp90kFormatter.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | ||||
| // vim: set et sw=2 ts=2:
 | ||||
| //
 | ||||
| // This file is part of Moonfire NVR, a security camera digital video recorder.
 | ||||
| // Copyright (C) 2018 Dolf Starreveld <dolf@starreveld.com>
 | ||||
| //
 | ||||
| // This program is free software: you can redistribute it and/or modify
 | ||||
| // it under the terms of the GNU General Public License as published by
 | ||||
| // the Free Software Foundation, either version 3 of the License, or
 | ||||
| // (at your option) any later version.
 | ||||
| //
 | ||||
| // In addition, as a special exception, the copyright holders give
 | ||||
| // permission to link the code of portions of this program with the
 | ||||
| // OpenSSL library under certain conditions as described in each
 | ||||
| // individual source file, and distribute linked combinations including
 | ||||
| // the two.
 | ||||
| //
 | ||||
| // You must obey the GNU General Public License in all respects for all
 | ||||
| // of the code used other than OpenSSL. If you modify file(s) with this
 | ||||
| // exception, you may extend this exception to your version of the
 | ||||
| // file(s), but you are not obligated to do so. If you do not wish to do
 | ||||
| // so, delete this exception statement from your version. If you delete
 | ||||
| // this exception statement from all source files in the program, then
 | ||||
| // also delete it here.
 | ||||
| //
 | ||||
| // This program 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 General Public License for more details.
 | ||||
| //
 | ||||
| // You should have received a copy of the GNU General Public License
 | ||||
| // along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| 
 | ||||
| import TimeFormatter from './TimeFormatter'; | ||||
| 
 | ||||
| 
 | ||||
| export const internalTimeFormat = 'YYYY-MM-DDTHH:mm:ss:FFFFFZ'; | ||||
| 
 | ||||
| /** | ||||
|  * Specialized class similar to TimeFormatter but forcing a specific time format | ||||
|  * for internal usage purposes. | ||||
|  */ | ||||
| export default class TimeStamp90kFormatter { | ||||
|   /** | ||||
|    * Construct from just a timezone specification. | ||||
|    * | ||||
|    * @param  {String} tz Timezone | ||||
|    */ | ||||
|   constructor(tz) { | ||||
|     this._formatter = new TimeFormatter(internalTimeFormat, tz); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Format a timestamp in 90k units using internal format. | ||||
|    * | ||||
|    * @param {Number} ts90k timestamp in 90,000ths of a second resolution | ||||
|    * @return {String}        Formatted timestamp | ||||
|    */ | ||||
|   formatTimeStamp90k(ts90k) { | ||||
|     return this._formatter.formatTimeStamp90k(ts90k); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Given two timestamp return formatted versions of both, where the second | ||||
|    * one may have been shortened if it falls on the same date as the first one. | ||||
|    * | ||||
|    * @param  {Number} ts1 First timestamp in 90k units | ||||
|    * @param  {Number} ts2 Secodn timestamp in 90k units | ||||
|    * @return {Array}     Array with two elements: [ ts1Formatted, ts2Formatted ] | ||||
|    */ | ||||
|   formatSameDayShortened(ts1, ts2) { | ||||
|     let ts1Formatted = this.formatTimeStamp90k(ts1); | ||||
|     let ts2Formatted = this.formatTimeStamp90k(ts2); | ||||
|     let timePos = this._formatter.formatStr.indexOf('T'); | ||||
|     if (timePos != -1) { | ||||
|       const datePortion = ts1Formatted.substr(0, timePos); | ||||
|       ts1Formatted = datePortion + ' ' + ts1Formatted.substr(timePos + 1); | ||||
|       if (ts2Formatted.startsWith(datePortion)) { | ||||
|         ts2Formatted = ts2Formatted.substr(timePos + 1); | ||||
|       } | ||||
|     } | ||||
|     return [ts1Formatted, ts2Formatted]; | ||||
|   } | ||||
| } | ||||
| @ -31,20 +31,10 @@ | ||||
| // along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| 
 | ||||
| import $ from 'jquery'; | ||||
| import 'jquery-ui/themes/base/button.css'; | ||||
| import 'jquery-ui/themes/base/core.css'; | ||||
| import 'jquery-ui/themes/base/datepicker.css'; | ||||
| import 'jquery-ui/themes/base/dialog.css'; | ||||
| import 'jquery-ui/themes/base/resizable.css'; | ||||
| import 'jquery-ui/themes/base/theme.css'; | ||||
| import 'jquery-ui/themes/base/tooltip.css'; | ||||
| import 'jquery-ui/ui/widgets/datepicker'; | ||||
| import 'jquery-ui/ui/widgets/dialog'; | ||||
| import 'jquery-ui/ui/widgets/tooltip'; | ||||
| 
 | ||||
| import DatePickerView from './DatePickerView'; | ||||
| import CalendarTSRange from '../models/CalendarTSRange'; | ||||
| import {TimeStamp90kFormatter} from '../support/TimeFormatter'; | ||||
| import TimeStamp90kFormatter from '../support/TimeStamp90kFormatter'; | ||||
| import Time90kParser from '../support/Time90kParser'; | ||||
| 
 | ||||
| /** | ||||
| @ -177,10 +167,11 @@ export default class CalendarView { | ||||
| 
 | ||||
|     if (this._sameDay) { | ||||
|       fromPickerView.option({ | ||||
|         dateFormat: $.datepicker.ISO_8601, | ||||
|         dateFormat: DatePickerView.datepicker.ISO_8601, | ||||
|         minDate: minDateStr, | ||||
|         maxDate: maxDateStr, | ||||
|         onSelect: (dateStr, picker) => this._updateRangeDates(dateStr, dateStr), | ||||
|         onSelect: (dateStr /* , picker */) => | ||||
|           this._updateRangeDates(dateStr, dateStr), | ||||
|         beforeShowDay: beforeShowDay, | ||||
|         disabled: false, | ||||
|       }); | ||||
| @ -188,21 +179,21 @@ export default class CalendarView { | ||||
|       toPickerView.activate(); // Default state, but alive
 | ||||
|     } else { | ||||
|       fromPickerView.option({ | ||||
|         dateFormat: $.datepicker.ISO_8601, | ||||
|         dateFormat: DatePickerView.datepicker.ISO_8601, | ||||
|         minDate: minDateStr, | ||||
|         onSelect: (dateStr, picker) => { | ||||
|           toPickerView.option('minDate', this.fromDateISO); | ||||
|         onSelect: (dateStr /* , picker */) => { | ||||
|           toPickerView.minDate = this.fromDateISO; | ||||
|           this._updateRangeDates(dateStr, this.toDateISO); | ||||
|         }, | ||||
|         beforeShowDay: beforeShowDay, | ||||
|         disabled: false, | ||||
|       }); | ||||
|       toPickerView.option({ | ||||
|         dateFormat: $.datepicker.ISO_8601, | ||||
|         dateFormat: DatePickerView.datepicker.ISO_8601, | ||||
|         minDate: fromPickerView.dateISO, | ||||
|         maxDate: maxDateStr, | ||||
|         onSelect: (dateStr, picker) => { | ||||
|           fromPickerView.option('maxDate', this.toDateISO); | ||||
|         onSelect: (dateStr /* , picker */) => { | ||||
|           fromPickerView.maxDate = this.toDateISO; | ||||
|           this._updateRangeDates(this.fromDateISO, dateStr); | ||||
|         }, | ||||
|         beforeShowDay: beforeShowDay, | ||||
| @ -228,7 +219,7 @@ export default class CalendarView { | ||||
|    * The change requires updating the selected range and then informing | ||||
|    * any listeners that the range has changed (so they can update data). | ||||
|    * | ||||
|    * @param  {Object}  event Time Event on DOM that triggered calling this | ||||
|    * @param  {event}  event       DOM event that triggered us | ||||
|    * @param  {Boolean} isEnd      True if this is for end time | ||||
|    */ | ||||
|   _pickerTimeChanged(event, isEnd) { | ||||
| @ -239,7 +230,7 @@ export default class CalendarView { | ||||
|       ? selectedRange.setEndTime(newTimeStr) | ||||
|       : selectedRange.setStartTime(newTimeStr); | ||||
|     if (parsedTS === null) { | ||||
|       console.log('bad time change'); | ||||
|       console.warn('bad time change'); | ||||
|       $(pickerElement).addClass('ui-state-error'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @ -83,7 +83,7 @@ export default class CameraView { | ||||
|     this._enabled = enabled; | ||||
|     this.recordingsView.show = enabled; | ||||
|     console.log( | ||||
|       'Camera ', | ||||
|       'Camera %s %s', | ||||
|       this.camera.shortName, | ||||
|       this.enabled ? 'enabled' : 'disabled' | ||||
|     ); | ||||
|  | ||||
| @ -32,10 +32,27 @@ | ||||
| 
 | ||||
| import $ from 'jquery'; | ||||
| 
 | ||||
| import 'jquery-ui/themes/base/core.css'; | ||||
| import 'jquery-ui/themes/base/datepicker.css'; | ||||
| import 'jquery-ui/themes/base/theme.css'; | ||||
| import 'jquery-ui/ui/widgets/datepicker'; | ||||
| 
 | ||||
| /** | ||||
|  * Class to encapsulate datepicker UI widget from jQuery. | ||||
|  */ | ||||
| export default class DatePickerView { | ||||
|   /** | ||||
|    * Get the singleton datepicker instance. | ||||
|    * | ||||
|    * This is useful for accessing implementation constants, such as | ||||
|    * date formats etc. | ||||
|    * | ||||
|    * @return {jQuery.datepicker} JQuery datepicker instance | ||||
|    */ | ||||
|   static get datepicker() { | ||||
|     return $.datepicker; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Construct wapper an attach to a specified parent DOM node. | ||||
|    * | ||||
| @ -85,11 +102,11 @@ export default class DatePickerView { | ||||
|    * | ||||
|    * @return {Any} Function result | ||||
|    */ | ||||
|   _apply() { | ||||
|   _apply(...args) { | ||||
|     if (!this._alive) { | ||||
|       console.log('WARN: datepicker not constructed yet [' + this.domId + ']'); | ||||
|       console.warn('datepicker not constructed yet [%s]', this.domId); | ||||
|     } | ||||
|     return this._pickerElement.datepicker(...arguments); | ||||
|     return this._pickerElement.datepicker(...args); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -267,8 +267,8 @@ export default class RecordingsView { | ||||
|     $('tr.r', tbody).remove(); | ||||
|     this._recordings.forEach((r) => { | ||||
|       let row = $('<tr class="r" />'); | ||||
|       row.append(_columnOrder.map((k) => $('<td/>'))); | ||||
|       row.on('click', (e) => { | ||||
|       row.append(_columnOrder.map(() => $('<td/>'))); | ||||
|       row.on('click', () => { | ||||
|         console.log('Video clicked'); | ||||
|         if (this._clickHandler !== null) { | ||||
|           console.log('Video clicked handler call'); | ||||
|  | ||||
							
								
								
									
										99
									
								
								ui-src/lib/views/VideoDialogView.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								ui-src/lib/views/VideoDialogView.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,99 @@ | ||||
| // vim: set et sw=2 ts=2:
 | ||||
| //
 | ||||
| // This file is part of Moonfire NVR, a security camera digital video recorder.
 | ||||
| // Copyright (C) 2018 Dolf Starreveld <dolf@starreveld.com>
 | ||||
| //
 | ||||
| // This program is free software: you can redistribute it and/or modify
 | ||||
| // it under the terms of the GNU General Public License as published by
 | ||||
| // the Free Software Foundation, either version 3 of the License, or
 | ||||
| // (at your option) any later version.
 | ||||
| //
 | ||||
| // In addition, as a special exception, the copyright holders give
 | ||||
| // permission to link the code of portions of this program with the
 | ||||
| // OpenSSL library under certain conditions as described in each
 | ||||
| // individual source file, and distribute linked combinations including
 | ||||
| // the two.
 | ||||
| //
 | ||||
| // You must obey the GNU General Public License in all respects for all
 | ||||
| // of the code used other than OpenSSL. If you modify file(s) with this
 | ||||
| // exception, you may extend this exception to your version of the
 | ||||
| // file(s), but you are not obligated to do so. If you do not wish to do
 | ||||
| // so, delete this exception statement from your version. If you delete
 | ||||
| // this exception statement from all source files in the program, then
 | ||||
| // also delete it here.
 | ||||
| //
 | ||||
| // This program 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 General Public License for more details.
 | ||||
| //
 | ||||
| // You should have received a copy of the GNU General Public License
 | ||||
| // along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | ||||
| 
 | ||||
| import $ from 'jquery'; | ||||
| 
 | ||||
| import 'jquery-ui/themes/base/core.css'; | ||||
| import 'jquery-ui/themes/base/dialog.css'; | ||||
| import 'jquery-ui/themes/base/theme.css'; | ||||
| // This not needed for pure dialog, but we want it resizable
 | ||||
| import 'jquery-ui/themes/base/resizable.css'; | ||||
| 
 | ||||
| // Get dialog ui widget
 | ||||
| import 'jquery-ui/ui/widgets/dialog'; | ||||
| 
 | ||||
| /** | ||||
|  * Class to implement a simple jQuery dialog based video player. | ||||
|  */ | ||||
| export default class VideoDialogView { | ||||
|   /** | ||||
|    * Construct the player. | ||||
|    * | ||||
|    * This does not attach the player to the DOM anywhere! In fact, construction | ||||
|    * of the necessary video element is delayed until an attach is requested. | ||||
|    * Since the close of the video removes all traces of it in the DOM, this | ||||
|    * apprach allows repeated use by calling attach again! | ||||
|    */ | ||||
|   constructor() {} | ||||
| 
 | ||||
|   /** | ||||
|    * Attach the player to the specified DOM element. | ||||
|    * | ||||
|    * @param {Node} domElement DOM element to attach to | ||||
|    * @return {VideoDialogView} Returns "this" for chaining. | ||||
|    */ | ||||
|   attach(domElement) { | ||||
|     this._videoElement = $('<video controls preload="auto" autoplay="true" />'); | ||||
|     this._dialogElement = $('<div class="playdialog" />').append( | ||||
|       this._videoElement | ||||
|     ); | ||||
|     $(domElement).append(this._dialogElement); | ||||
|     return this; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Show the player, and start playing the given url. | ||||
|    * | ||||
|    * @param  {String} title Title of the video player | ||||
|    * @param  {Number} width Width of the player | ||||
|    * @param  {String} url   URL for source media | ||||
|    * @return {VideoDialogView}       Returns "this" for chaining. | ||||
|    */ | ||||
|   play(title, width, url) { | ||||
|     this._dialogElement.dialog({ | ||||
|       title: title, | ||||
|       width: width, | ||||
|       close: () => { | ||||
|         const videoDOMElement = this._videoElement[0]; | ||||
|         videoDOMElement.pause(); | ||||
|         videoDOMElement.src = ''; // Remove current source to stop loading
 | ||||
|         this._videoElement = null; | ||||
|         this._dialogElement.remove(); | ||||
|         this._dialogElement = null; | ||||
|       }, | ||||
|     }); | ||||
|     // Now that dialog is up, set the src so video starts
 | ||||
|     console.log('Video url: ' + url); | ||||
|     this._videoElement.attr('src', url); | ||||
|     return this; | ||||
|   } | ||||
| } | ||||
| @ -48,32 +48,41 @@ module.exports = (env, args) => { | ||||
|       publicPath: '/', | ||||
|     }, | ||||
|     module: { | ||||
|       rules: [{ | ||||
|         test: /\.js$/, | ||||
|         loader: 'babel-loader', | ||||
|         query: { | ||||
|           'presets': ['env'], | ||||
|       rules: [ | ||||
|         { | ||||
|           test: /\.js$/, | ||||
|           loader: 'babel-loader', | ||||
|           query: { | ||||
|             presets: ['env', {modules: false}], | ||||
|           }, | ||||
|           exclude: /(node_modules|bower_components)/, | ||||
|           include: ['./ui-src'], | ||||
|         }, | ||||
|         exclude: /(node_modules|bower_components)/, | ||||
|         include: ['./ui-src'], | ||||
|       }, { | ||||
|         test: /\.png$/, | ||||
|         use: ['file-loader'], | ||||
|       }, { | ||||
|         test: /\.ico$/, | ||||
|         use: [ | ||||
|           { | ||||
|             loader: 'file-loader', | ||||
|             options: { | ||||
|               name: '[name].[ext]' | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       }, { | ||||
|         // Load css and then in-line in head
 | ||||
|         test: /\.css$/, | ||||
|         loader: 'style-loader!css-loader', | ||||
|       }], | ||||
|         { | ||||
|           test: /\.png$/, | ||||
|           use: ['file-loader'], | ||||
|         }, | ||||
|         { | ||||
|           test: /\.ico$/, | ||||
|           use: [ | ||||
|             { | ||||
|               loader: 'file-loader', | ||||
|               options: { | ||||
|                 name: '[name].[ext]', | ||||
|               }, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|         { | ||||
|           test: /\.png$/, | ||||
|           use: ['file-loader'], | ||||
|         }, | ||||
|         { | ||||
|           // Load css and then in-line in head
 | ||||
|           test: /\.css$/, | ||||
|           use: ['style-loader', 'css-loader'], | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|     plugins: [ | ||||
|       new webpack.DefinePlugin({ | ||||
| @ -83,14 +92,20 @@ module.exports = (env, args) => { | ||||
|       new HtmlWebpackPlugin({ | ||||
|         title: nvrSettings.app_title, | ||||
|         filename: 'index.html', | ||||
|         template: path.join(nvrSettings._paths.app_src_dir, 'assets', 'index.html'), | ||||
|         template: path.join( | ||||
|           nvrSettings._paths.app_src_dir, | ||||
|           'assets', | ||||
|           'index.html' | ||||
|         ), | ||||
|       }), | ||||
|       new webpack.NormalModuleReplacementPlugin( | ||||
|         /node_modules\/moment\/moment\.js$/, | ||||
|         './min/moment.min.js'), | ||||
|         './min/moment.min.js' | ||||
|       ), | ||||
|       new webpack.NormalModuleReplacementPlugin( | ||||
|         /node_modules\/moment-timezone\/index\.js$/, | ||||
|         './builds/moment-timezone-with-data-2012-2022.min.js'), | ||||
|         './builds/moment-timezone-with-data-2012-2022.min.js' | ||||
|       ), | ||||
|     ], | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @ -44,6 +44,7 @@ module.exports = (env, args) => { | ||||
|     }, | ||||
|     devtool: 'inline-source-map', | ||||
|     optimization: { | ||||
|       minimize: false, | ||||
|       namedChunks: true, | ||||
|     }, | ||||
|     devServer: { | ||||
| @ -54,11 +55,11 @@ module.exports = (env, args) => { | ||||
|       hot: true, | ||||
|       clientLogLevel: 'info', | ||||
|       proxy: { | ||||
|         '/api': `http://${nvrSettings.moonfire.server}:${nvrSettings.moonfire.port}`, | ||||
|         '/api': `http://${nvrSettings.moonfire.server}:${ | ||||
|           nvrSettings.moonfire.port | ||||
|         }`,
 | ||||
|       }, | ||||
|     }, | ||||
|     plugins: [ | ||||
|       new webpack.HotModuleReplacementPlugin(), | ||||
|     ], | ||||
|     plugins: [new webpack.HotModuleReplacementPlugin()], | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| @ -42,34 +42,34 @@ const merge = require('webpack-merge'); | ||||
|  * found), we throw an exception. | ||||
|  * | ||||
|  * If the module that is require-d is a function, it will be executed, | ||||
|  * passing the "env" and "args" parameters from the settingsConfig to it. | ||||
|  * passing the "env" and "args" parameters to it. | ||||
|  * The function should return a map. | ||||
|  * | ||||
|  * @param  {String} path            Path to be passed to require() | ||||
|  * @param  {object} settingsConfig  Settings passed to new Settings() | ||||
|  * @param  {String} requiredPath    Path to be passed to require() | ||||
|  * @param  {object} env             webpack's "env" on invocation | ||||
|  * @param  {object} args            webpack's "args" on invocation (options) | ||||
|  * @param  {Boolean} optional       True file not to exist | ||||
|  * @return {object}                 The module, or {} if not found (optional) | ||||
|  */ | ||||
| function requireHelper(path, settingsConfig, optional) { | ||||
| function requireHelper(requiredPath, env, args, optional) { | ||||
|   let module = {}; | ||||
|   try { | ||||
|     require.resolve(path); // Throws if not found
 | ||||
|     require.resolve(requiredPath); // Throws if not found
 | ||||
|     try { | ||||
|       module = require(path); | ||||
|       if (typeof(module) === 'function') { | ||||
|         module = module(settingsConfig.env, settingsConfig.args); | ||||
|       module = require(requiredPath); | ||||
|       if (typeof module === 'function') { | ||||
|         module = module(env, args); | ||||
|       } | ||||
|       // Get owned properties only: now a literal map
 | ||||
|       module = Object.assign({}, require(path).settings); | ||||
|       module = Object.assign({}, require(requiredPath).settings); | ||||
|     } catch (e) { | ||||
|       throw new Error('Settings file (' + path + ') has errors.'); | ||||
|       throw new Error('Settings file (' + requiredPath + ') has errors.'); | ||||
|     } | ||||
|   } catch (e) { | ||||
|     if (!optional) { | ||||
|       throw new Error('Settings file (' + path + ') not found.'); | ||||
|       throw new Error('Settings file (' + requiredPath + ') not found.'); | ||||
|     } | ||||
|   } | ||||
|   const args = settingsConfig.args; | ||||
|   const webpackMode = (args ? args.mode : null) || 'none'; | ||||
|   const modes = module.webpack_mode || {}; | ||||
|   delete module.webpack_mode; // Not modifying original module. We have a copy!
 | ||||
| @ -188,13 +188,14 @@ class Settings { | ||||
|     const secondaryPath = path.resolve(projectRoot, secondaryFile); | ||||
| 
 | ||||
|     // Check if we can resolve the primary file and if we can, require it.
 | ||||
|     const _settings = | ||||
|       requireHelper(primaryPath, this.settings_config, optional); | ||||
|     const _settings = requireHelper(primaryPath, env, args, optional); | ||||
| 
 | ||||
|     // Merge secondary override file, if it exists
 | ||||
|     this.settings = merge(_settings, | ||||
|       requireHelper(secondaryPath, this.settings_config, true)); | ||||
|   }; | ||||
|     this.settings = merge( | ||||
|       _settings, | ||||
|       requireHelper(secondaryPath, env, args, true) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Take one or more webpack configurations and merge them. | ||||
| @ -212,12 +213,14 @@ class Settings { | ||||
|    */ | ||||
|   webpackMerge(...packs) { | ||||
|     const unpack = (webpackConfig) => { | ||||
|       if ((typeof(webpackConfig) === 'string') || | ||||
|         (webpackConfig instanceof String)) { | ||||
|       if ( | ||||
|         typeof webpackConfig === 'string' || | ||||
|         webpackConfig instanceof String | ||||
|       ) { | ||||
|         webpackConfig = require(webpackConfig); | ||||
|       } | ||||
|       const config = this.settings_config; | ||||
|       if (typeof(webpackConfig) === 'function') { | ||||
|       if (typeof webpackConfig === 'function') { | ||||
|         return webpackConfig(config.env, config.args); | ||||
|       } | ||||
|       return webpackConfig; | ||||
|  | ||||
| @ -42,34 +42,76 @@ module.exports = (env, args) => { | ||||
|   const nvrSettings = settingsObject.settings; | ||||
| 
 | ||||
|   return settingsObject.webpackMerge(baseConfig, { | ||||
|     //devtool: 'cheap-module-source-map',
 | ||||
|     devtool: 'cheap-module-source-map', | ||||
|     module: { | ||||
|       rules: [{ | ||||
|         test: /\.html$/, | ||||
|         loader: 'html-loader', | ||||
|         query: { | ||||
|           minimize: true, | ||||
|       rules: [ | ||||
|         { | ||||
|           test: /\.html$/, | ||||
|           loader: 'html-loader', | ||||
|           query: { | ||||
|             minimize: true, | ||||
|           }, | ||||
|         }, | ||||
|       }], | ||||
|       ], | ||||
|     }, | ||||
|     optimization: { | ||||
|       minimize: true, | ||||
|       minimizer: [ | ||||
|         { | ||||
|           apply: (compiler) => { | ||||
|             /** | ||||
|              * Setup the UglifyJsPlugin as webpack4 does, plus options | ||||
|              * we decide to override. | ||||
|              */ | ||||
|             // Lazy load the uglifyjs plugin
 | ||||
|             const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); | ||||
|             new UglifyJsPlugin({ | ||||
|               cache: true, // webpack4: default
 | ||||
|               parallel: true, // webpack4: default
 | ||||
|               sourceMap: | ||||
|                 (args.devtool && /source-?map/.test(args.devtool)) || | ||||
|                 (args.plugins && | ||||
|                   args.plugins.some( | ||||
|                     (p) => p instanceof webpack.SourceMapDevToolPlugin | ||||
|                   )), | ||||
|               uglifyOptions: { | ||||
|                 compress: { | ||||
|                   drop_console: true, // Remove all console.log etc.
 | ||||
|                   keep_infinity: true, // Do not change to 1/0
 | ||||
|                   warnings: false, // Do not warn when dropping
 | ||||
|                 }, | ||||
|                 output: { | ||||
|                   // Eliminate most comments, but not marked ones
 | ||||
|                   comments: 'some', | ||||
|                 }, | ||||
|               }, | ||||
|             }).apply(compiler); | ||||
|           }, | ||||
|         }, | ||||
|       ], | ||||
|       splitChunks: { | ||||
|         minSize: 30000, | ||||
|         minChunks: 1, | ||||
|         maxAsyncRequests: 5, | ||||
|         maxInitialRequests: 3, | ||||
|         maxInitialRequests: 4, | ||||
|         cacheGroups: { | ||||
|           default: { | ||||
|           'default': { | ||||
|             minChunks: 2, | ||||
|             priority: -20, | ||||
|           }, | ||||
|           commons: { | ||||
|             name: 'commons', | ||||
|           'jquery-ui': { | ||||
|             test: /[\\/]node_modules[\\/]jquery-ui[\\/]/, | ||||
|             name: 'jquery-ui', | ||||
|             chunks: 'all', | ||||
|             minChunks: 2, | ||||
|             priority: -5, | ||||
|           }, | ||||
|           vendors: { | ||||
|           'jquery': { | ||||
|             test: /[\\/]node_modules[\\/]jquery[\\/]/, | ||||
|             name: 'jquery', | ||||
|             chunks: 'all', | ||||
|             priority: -5, | ||||
|           }, | ||||
|           'vendors': { | ||||
|             test: /[\\/]node_modules[\\/]/, | ||||
|             name: 'vendor', | ||||
|             chunks: 'all', | ||||
| @ -89,9 +131,6 @@ module.exports = (env, args) => { | ||||
|         threshold: 10240, | ||||
|         minRatio: 0.8, | ||||
|       }), | ||||
|       new webpack.NormalModuleReplacementPlugin( | ||||
|         /node_modules\/jquery\/dist\/jquery\.js$/, | ||||
|         './jquery.min.js'), | ||||
|     ], | ||||
|   }); | ||||
| }; | ||||
|  | ||||
							
								
								
									
										37
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								yarn.lock
									
									
									
									
									
								
							| @ -865,6 +865,17 @@ babel-plugin-transform-flow-strip-types@^6.8.0: | ||||
|     babel-plugin-syntax-flow "^6.18.0" | ||||
|     babel-runtime "^6.22.0" | ||||
| 
 | ||||
| babel-plugin-transform-imports@^1.5.0: | ||||
|   version "1.5.0" | ||||
|   resolved "https://registry.yarnpkg.com/babel-plugin-transform-imports/-/babel-plugin-transform-imports-1.5.0.tgz#3105082ab489b1cee162e42d2ffe7b8f7c685f2e" | ||||
|   dependencies: | ||||
|     babel-types "^6.6.0" | ||||
|     is-valid-path "^0.1.1" | ||||
|     lodash.camelcase "^4.3.0" | ||||
|     lodash.findkey "^4.6.0" | ||||
|     lodash.kebabcase "^4.1.1" | ||||
|     lodash.snakecase "^4.1.1" | ||||
| 
 | ||||
| babel-plugin-transform-inline-consecutive-adds@^0.3.0: | ||||
|   version "0.3.0" | ||||
|   resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.3.0.tgz#f07d93689c0002ed2b2b62969bdd99f734e03f57" | ||||
| @ -1095,7 +1106,7 @@ babel-traverse@^6.24.1, babel-traverse@^6.26.0: | ||||
|     invariant "^2.2.2" | ||||
|     lodash "^4.17.4" | ||||
| 
 | ||||
| babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0: | ||||
| babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0, babel-types@^6.6.0: | ||||
|   version "6.26.0" | ||||
|   resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" | ||||
|   dependencies: | ||||
| @ -3625,6 +3636,12 @@ is-glob@^4.0.0: | ||||
|   dependencies: | ||||
|     is-extglob "^2.1.1" | ||||
| 
 | ||||
| is-invalid-path@^0.1.0: | ||||
|   version "0.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/is-invalid-path/-/is-invalid-path-0.1.0.tgz#307a855b3cf1a938b44ea70d2c61106053714f34" | ||||
|   dependencies: | ||||
|     is-glob "^2.0.0" | ||||
| 
 | ||||
| is-number@^2.1.0: | ||||
|   version "2.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" | ||||
| @ -3737,6 +3754,12 @@ is-utf8@^0.2.0: | ||||
|   version "0.2.1" | ||||
|   resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" | ||||
| 
 | ||||
| is-valid-path@^0.1.1: | ||||
|   version "0.1.1" | ||||
|   resolved "https://registry.yarnpkg.com/is-valid-path/-/is-valid-path-0.1.1.tgz#110f9ff74c37f663e1ec7915eb451f2db93ac9df" | ||||
|   dependencies: | ||||
|     is-invalid-path "^0.1.0" | ||||
| 
 | ||||
| is-windows@^1.0.1, is-windows@^1.0.2: | ||||
|   version "1.0.2" | ||||
|   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" | ||||
| @ -4043,14 +4066,26 @@ lodash.camelcase@^4.3.0: | ||||
|   version "4.3.0" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" | ||||
| 
 | ||||
| lodash.findkey@^4.6.0: | ||||
|   version "4.6.0" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.findkey/-/lodash.findkey-4.6.0.tgz#83058e903b51cbb759d09ccf546dea3ea39c4718" | ||||
| 
 | ||||
| lodash.isplainobject@^4.0.6: | ||||
|   version "4.0.6" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" | ||||
| 
 | ||||
| lodash.kebabcase@^4.1.1: | ||||
|   version "4.1.1" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" | ||||
| 
 | ||||
| lodash.memoize@^4.1.2: | ||||
|   version "4.1.2" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" | ||||
| 
 | ||||
| lodash.snakecase@^4.1.1: | ||||
|   version "4.1.1" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" | ||||
| 
 | ||||
| lodash.some@^4.6.0: | ||||
|   version "4.6.0" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user