diff --git a/.env b/.env.example similarity index 100% rename from .env rename to .env.example diff --git a/.eslintrc b/.eslintrc index c15015b..848a361 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,7 @@ "google": true, }, "rules": { - "import/no-unresolved": [2, { ignore: ['^components/', '^containers/', '^services/', '^layouts/', '^store/', '^api/', '^routes/'] }], + "import/no-unresolved": [2, { ignore: ['^components/', '^containers/', '^services/', '^layouts/', '^store/', '^api/', '^routes/', '^Const'] }], // temporary for in-progress features "no-alert": 0, diff --git a/.gitignore b/.gitignore index e043f42..20c36db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ node_modules dist coverage .tmp +/.env diff --git a/README.md b/README.md index 0d4c26c..f8cfdec 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## Configuration -Configuration files are located under `config` dir. +Configuration files are located under `config` and `src/config` directories. See Guild https://github.com/lorenwest/node-config/wiki/Configuration-Files |Name|Description| @@ -18,7 +18,12 @@ See Guild https://github.com/lorenwest/node-config/wiki/Configuration-Files |`PORT`| The port to listen| |`GOOGLE_API_KEY`| The google api key see (https://developers.google.com/maps/documentation/javascript/get-api-key#key)| |`API_BASE_URL`| The base URL for Drone API | +|`REACT_APP_API_BASE_PATH`| The React app api base path`| +|`REACT_APP_SOCKET_URL`| The React app app socket url`| +|`REACT_APP_AUTH0_CLIEND_ID`| The React app auth0 client id`| +|`REACT_APP_AUTH0_DOMAIN`| The React app auth0 domain`| +Environment variables will be loaded from the .env file during build. Create the .env file based on the provided env.example ## Install dependencies `npm i` diff --git a/config/default.js b/config/default.js index 6bc8230..f1bbbf3 100644 --- a/config/default.js +++ b/config/default.js @@ -8,5 +8,4 @@ module.exports = { // below env variables are visible in frontend GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || 'AIzaSyCrL-O319wNJK8kk8J_JAYsWgu6yo5YsDI', - API_BASE_PATH: process.env.API_BASE_PATH || 'https://kb-dsp-server-dev.herokuapp.com/', }; diff --git a/config/development.js b/config/development.js index 6bc8230..f1bbbf3 100644 --- a/config/development.js +++ b/config/development.js @@ -8,5 +8,4 @@ module.exports = { // below env variables are visible in frontend GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || 'AIzaSyCrL-O319wNJK8kk8J_JAYsWgu6yo5YsDI', - API_BASE_PATH: process.env.API_BASE_PATH || 'https://kb-dsp-server-dev.herokuapp.com/', }; diff --git a/config/production.js b/config/production.js index 04e870d..2bdcc33 100644 --- a/config/production.js +++ b/config/production.js @@ -8,5 +8,4 @@ module.exports = { // below env variables are visible in frontend GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || 'AIzaSyCrL-O319wNJK8kk8J_JAYsWgu6yo5YsDI', - API_BASE_PATH: process.env.API_BASE_PATH || 'https://kb-dsp-server-dev.herokuapp.com/', }; diff --git a/config/staging.js b/config/staging.js index 6bc8230..f1bbbf3 100644 --- a/config/staging.js +++ b/config/staging.js @@ -8,5 +8,4 @@ module.exports = { // below env variables are visible in frontend GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || 'AIzaSyCrL-O319wNJK8kk8J_JAYsWgu6yo5YsDI', - API_BASE_PATH: process.env.API_BASE_PATH || 'https://kb-dsp-server-dev.herokuapp.com/', }; diff --git a/config/test.js b/config/test.js index 6bc8230..f1bbbf3 100644 --- a/config/test.js +++ b/config/test.js @@ -8,5 +8,4 @@ module.exports = { // below env variables are visible in frontend GOOGLE_API_KEY: process.env.GOOGLE_API_KEY || 'AIzaSyCrL-O319wNJK8kk8J_JAYsWgu6yo5YsDI', - API_BASE_PATH: process.env.API_BASE_PATH || 'https://kb-dsp-server-dev.herokuapp.com/', }; diff --git a/envSample b/envSample deleted file mode 100644 index 4a42b68..0000000 --- a/envSample +++ /dev/null @@ -1,5 +0,0 @@ -REACT_APP_API_BASE_PATH=https://kb-dsp-server.herokuapp.com -REACT_APP_SOCKET_URL=https://kb-dsp-server.herokuapp.com -REACT_APP_AUTH0_CLIEND_ID=3CGKzjS2nVSqHxHHE64RhvvKY6e0TYpK -REACT_APP_AUTH0_DOMAIN=dronetest.auth0.com -REACT_APP_GOOGLE_API_KEY=AIzaSyCR3jfBdv9prCBYBOf-fPUDhjPP4K05YjE diff --git a/package.json b/package.json index b04c246..d4eb605 100644 --- a/package.json +++ b/package.json @@ -5,20 +5,20 @@ "main": "index.js", "scripts": { "dev": "cross-env NODE_ENV=development nodemon server", - "start": "node --max-old-space-size=512 server", - "build": "node --max-old-space-size=512 ./node_modules/webpack/bin/webpack --bail --progress --build --tc", + "start": "node --max-old-space-size=384 server", + "build": "node --max-old-space-size=384 ./node_modules/webpack/bin/webpack --bail --progress --build --tc", "lint": "eslint --ext jsx --ext js .", "lint:fix": "npm run lint -- --fix", "test": "mocha-webpack --require setup-test.js --webpack-config webpack.config-test.js \"src/**/*.spec.(jsx|js)\"", - "postinstall":"npm run build", - "heroku:prod:init":"git remote remove production && heroku create --remote production && heroku config:set NODE_ENV=production --remote production", - "heroku:dev:init": "git remote remove dev && heroku create --remote dev && heroku config:set NODE_ENV=development NPM_CONFIG_PRODUCTION=false --remote dev", - "heroku:staging:init": "git remote remove staging && heroku create --remote staging && heroku config:set NODE_ENV=staging --remote staging", - "heroku:test:init": "git remote remove test && heroku create --remote test && heroku config:set NODE_ENV=test --remote test", - "heroku:prod:deploy":"git push production master", - "heroku:dev:deploy":"git push dev master", - "heroku:staging:deploy":"git push staging master", - "heroku:test:deploy":"git push test master" + "postinstall": "npm run build", + "heroku:prod:init": "git remote remove production && heroku create --remote production && heroku config:set NODE_ENV=production --remote production", + "heroku:dev:init": "git remote remove dev && heroku create --remote dev && heroku config:set NODE_ENV=production --remote dev", + "heroku:staging:init": "git remote remove staging && heroku create --remote staging && heroku config:set NODE_ENV=production --remote staging", + "heroku:test:init": "git remote remove test && heroku create --remote test && heroku config:set NODE_ENV=production --remote test", + "heroku:prod:deploy": "git push production master", + "heroku:dev:deploy": "git push dev master", + "heroku:staging:deploy": "git push staging master", + "heroku:test:deploy": "git push test master" }, "author": "", "license": "MIT", @@ -37,6 +37,7 @@ "babel-preset-stage-0": "^6.16.0", "babel-runtime": "^6.11.6", "bluebird": "^3.4.6", + "circle-to-polygon": "^1.0.0", "classnames": "^2.2.5", "config": "^1.24.0", "connect-history-api-fallback": "^1.3.0", @@ -44,6 +45,8 @@ "cross-env": "^3.1.2", "css-loader": "^0.23.0", "dateformat": "^2.0.0", + "dotenv": "^2.0.0", + "dotenv-webpack": "^1.3.1", "express": "^4.14.0", "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.9.0", @@ -61,21 +64,23 @@ "postcss-flexboxfixer": "0.0.5", "postcss-loader": "^0.13.0", "rc-calendar": "^7.5.1", + "rc-slider": "^5.4.0", "rc-tooltip": "^3.4.2", "react": "^15.3.2", "react-breadcrumbs": "^1.5.1", - "react-count-down": "^1.0.3", "react-click-outside": "^2.2.0", + "react-count-down": "^1.0.3", "react-css-modules": "^3.7.10", "react-date-picker": "^5.3.28", "react-dom": "^15.3.2", + "react-dropdown": "^1.2.0", "react-flexbox-grid": "^0.10.2", "react-google-maps": "^6.0.1", - "react-modal": "^1.5.2", - "react-dropdown": "^1.2.0", + "react-highcharts": "^11.0.0", "react-icheck": "^0.3.6", "react-input-range": "^0.9.3", - "react-highcharts": "^11.0.0", + "react-modal": "^1.5.2", + "react-portal": "^3.0.0", "react-redux": "^4.0.0", "react-redux-toastr": "^4.2.2", "react-router": "^2.8.1", @@ -84,8 +89,8 @@ "react-simple-dropdown": "^1.1.5", "react-slick": "^0.14.5", "react-star-rating-component": "^1.2.2", - "react-timeago": "^3.1.3", "react-tabs": "^0.8.2", + "react-timeago": "^3.1.3", "reactable": "^0.14.1", "redbox-react": "^1.2.10", "redux": "^3.0.0", @@ -95,8 +100,8 @@ "redux-logger": "^2.6.1", "redux-thunk": "^2.0.0", "sass-loader": "^4.0.0", - "socket.io-client": "^1.7.1", "slick-carousel": "^1.6.0", + "socket.io-client": "^1.7.1", "style-loader": "^0.13.0", "superagent": "^2.3.0", "superagent-promise": "^1.1.0", diff --git a/src/Const.js b/src/Const.js new file mode 100644 index 0000000..a7ccc4f --- /dev/null +++ b/src/Const.js @@ -0,0 +1,6 @@ + +export const GOOGLE_MAPS_BOUNDS_TIMEOUT = 1000; + +export default { + GOOGLE_MAPS_BOUNDS_TIMEOUT, +}; diff --git a/src/components/AdminHeader/AdminHeader.jsx b/src/components/AdminHeader/AdminHeader.jsx new file mode 100644 index 0000000..a822dd2 --- /dev/null +++ b/src/components/AdminHeader/AdminHeader.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import CSSModules from 'react-css-modules'; +import {Link} from 'react-router'; +import styles from './AdminHeader.scss'; + +export const AdminHeader = () => ( + +); + +AdminHeader.propTypes = { + +}; + +export default CSSModules(AdminHeader, styles); diff --git a/src/components/AdminHeader/AdminHeader.scss b/src/components/AdminHeader/AdminHeader.scss new file mode 100644 index 0000000..dcabbf5 --- /dev/null +++ b/src/components/AdminHeader/AdminHeader.scss @@ -0,0 +1,13 @@ +.admin-header { + composes: header from '../Header/Header.scss' +} + +.branding { + composes: branding from '../Header/Header.scss' +} + +.pages { + composes: pages from '../Header/Header.scss' +} + + diff --git a/src/components/AdminHeader/index.js b/src/components/AdminHeader/index.js new file mode 100644 index 0000000..6d761e9 --- /dev/null +++ b/src/components/AdminHeader/index.js @@ -0,0 +1,3 @@ +import AdminHeader from './AdminHeader'; + +export default AdminHeader; diff --git a/src/components/Button/Button.scss b/src/components/Button/Button.scss index 1536e6d..48494a9 100644 --- a/src/components/Button/Button.scss +++ b/src/components/Button/Button.scss @@ -3,6 +3,11 @@ color: white; border: none; font-weight: bold; + + &[disabled] { + opacity: 0.6; + cursor: not-allowed; + } } .color-gray { @@ -22,10 +27,17 @@ padding: 0 10px; } +.size-xs { + height: 20px; + padding: 0 5px; + font-size: 12px; + font-weight: normal; +} + .color-black { background: #4c4c4c; } .color-silver { background: #67879a; -} \ No newline at end of file +} diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx index f92f525..5467cd4 100644 --- a/src/components/Header/Header.jsx +++ b/src/components/Header/Header.jsx @@ -39,23 +39,23 @@ export const Header = ({ return (
  • ); diff --git a/src/components/Header/Header.scss b/src/components/Header/Header.scss index 354807c..af75609 100644 --- a/src/components/Header/Header.scss +++ b/src/components/Header/Header.scss @@ -10,7 +10,7 @@ margin: 0; width: 100%; display: flex; - + > li { height: 60px; display: flex; @@ -21,7 +21,7 @@ + li { border-left: 1px solid #484848; } - + &.search { flex-grow: 1; } @@ -64,16 +64,16 @@ align-items: center; padding: 8px 8px 0; border-bottom: 8px solid transparent; - &:hover, &:focus { + &:hover { border-bottom: 8px solid #315b95; } } - } + } :global { .active { - border-bottom: 5px solid #214A84; + border-bottom: 8px solid #214A84; } } } @@ -124,5 +124,5 @@ color: #fff; font-weight: bold; } - -} \ No newline at end of file + +} diff --git a/src/components/MapHistory/MapHistory.jsx b/src/components/MapHistory/MapHistory.jsx new file mode 100644 index 0000000..bfdbac1 --- /dev/null +++ b/src/components/MapHistory/MapHistory.jsx @@ -0,0 +1,239 @@ +import React, {PropTypes} from 'react'; +import CSSModules from 'react-css-modules'; +import _ from 'lodash'; +import moment from 'moment'; +import Slider from 'rc-slider'; +import 'rc-slider/assets/index.css'; +import styles from './MapHistory.scss'; + +const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +const tipFormatter = (v) => moment(v).format(DATE_FORMAT);// eslint-disable-line new-cap + +class MapHistory extends React.Component { + constructor(props) { + super(props); + + this.getBounds = this.getBounds.bind(this); + this.drawPath = this.drawPath.bind(this); + this.filterMarkers = this.filterMarkers.bind(this); + this.getDateBounds = this.getDateBounds.bind(this); + this.filterLocations = this.filterLocations.bind(this); + this.setDateRange = this.setDateRange.bind(this); + + if (props.locations.length > 0) { + this.getDateBounds(); + this.dateRange = this.dateBounds; + } + } + + componentDidMount() { + if (this.props.locations.length === 0) { + return; + } + const bounds = this.getBounds(); + const mapSettings = { + center: bounds.getCenter(), + minZoom: 3, + }; + + // create map + this.map = new google.maps.Map(this.node, mapSettings); + this.map.fitBounds(bounds); + this.map.addListener('zoom_changed', this.filterMarkers); + + // a overlay to translate from pixel to latlng and the reverse + this.overlay = new google.maps.OverlayView(); + this.overlay.draw = _.noop; + this.overlay.setMap(this.map); + + // a info window to show location created date + this.infoWindow = new google.maps.InfoWindow({ + pixelOffset: new google.maps.Size(0, -8), + }); + + this.filterLocations(); + } + + // get map's bounds based on locations + getBounds() { + const bounds = new google.maps.LatLngBounds(); + _.forEach(this.props.locations, (l) => { + bounds.extend(_.pick(l, 'lat', 'lng')); + }); + return bounds; + } + + // get date bounds of locations + getDateBounds() { + this.dateBounds = [ + new Date(this.props.locations[0].createdAt).getTime(), + new Date(this.props.locations[this.props.locations.length - 1].createdAt).getTime(), + ]; + } + + // set range of date to show locations + setDateRange(range) { + this.dateRange = range; + this.filterLocations(); + } + + // filter locations by date range and then draw path + filterLocations() { + this.locations = _.filter(this.props.locations, (l) => { + const time = new Date(l.createdAt).getTime(); + return time >= this.dateRange[0] && time <= this.dateRange[1]; + }); + + // interpolate start location if not existed + _.forEach(this.props.locations, (l, i, c) => { + const time1 = new Date(l.createdAt).getTime(); + if (time1 >= this.dateRange[0]) { + if (time1 > this.dateRange[0]) { + const time2 = new Date(c[i - 1].createdAt).getTime(); + const ratio = (this.dateRange[0] - time2) / (time1 - time2); + this.locations.unshift({ + createdAt: this.dateRange[0], + lat: c[i - 1].lat + ratio * (l.lat - c[i - 1].lat), + lng: c[i - 1].lng + ratio * (l.lng - c[i - 1].lng), + }); + } + return false; + } + return true; + }); + + // interpolate end location if not existed + _.forEachRight(this.props.locations, (l, i, c) => { + const time1 = new Date(l.createdAt).getTime(); + if (time1 <= this.dateRange[1]) { + if (time1 < this.dateRange[1]) { + const time2 = new Date(c[i + 1].createdAt).getTime(); + const ratio = (this.dateRange[1] - time1) / (time2 - time1); + this.locations.push({ + createdAt: this.dateRange[1], + lat: l.lat + ratio * (c[i + 1].lat - l.lat), + lng: l.lng + ratio * (c[i + 1].lng - l.lng), + }); + } + return false; + } + return true; + }); + + this.drawPath(); + } + + // hide markers if one is too close to next + filterMarkers() { + this.omitMarkers = 0; + let lastMarker; + _.forEach(this.markers, (m) => { + if (!lastMarker) { + lastMarker = m; + m.setVisible(true); + } else { + const p1 = this.overlay.getProjection().fromLatLngToDivPixel(m.getPosition()); + const p2 = this.overlay.getProjection().fromLatLngToDivPixel(lastMarker.getPosition()); + const dist = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); + // remove some location to avoid overlap + if (dist > 20) { + lastMarker = m; + m.setVisible(true); + } else { + m.setVisible(false); + ++this.omitMarkers; + } + } + }); + this.forceUpdate(); + } + + // draw locations path + drawPath() { + // clear exsiting path + if (this.path) { + this.path.setMap(null); + } + + // create new path based on filtered locations + this.path = new google.maps.Polyline({ + path: _.map(this.locations, (l) => (_.pick(l, 'lat', 'lng'))), + map: this.map, + strokeColor: '#f00', + strokeWeight: 2, + }); + + // clear exsiting markers + if (this.markers) { + _.forEach(this.markers, (m) => { + m.setMap(null); + }); + } + + // create markers based on filtered locations + this.markers = _.map(this.locations, (l, i) => { + const marker = new google.maps.Marker({ + crossOnDrag: false, + cursor: 'pointer', + position: _.pick(l, 'lat', 'lng'), + icon: { + path: google.maps.SymbolPath.CIRCLE, + fillOpacity: 0.5, + fillColor: i === 0 ? '#3e0' : '#f00', + strokeOpacity: 1.0, + strokeColor: '#fff000', + strokeWeight: 1.0, + scale: 10, + }, + map: this.map, + }); + + // show info window when mouse hover + marker.addListener('mouseover', () => { + this.infoWindow.setContent(new moment(l.createdAt).format(DATE_FORMAT)); // eslint-disable-line new-cap + this.infoWindow.setPosition(marker.getPosition()); + this.infoWindow.open(this.map); + }); + marker.addListener('mouseout', () => { + this.infoWindow.close(); + }); + + return marker; + }); + + this.filterMarkers(); + } + + render() { + return ( + this.props.locations.length === 0 ? + (
    No location history
    ) : + (
    +
    { + this.node = node; + }} + /> +
    +
    + +
    +
    +
    Showing locations from {moment(this.dateRange[0]).format(DATE_FORMAT)} to {moment(this.dateRange[1]).format(DATE_FORMAT)}
    + {this.omitMarkers > 0 ? (
    {`${this.omitMarkers} locations are omitted, zoom in to show more`}
    ) : null} +
    +
    +
    ) + ); + } +} + +MapHistory.propTypes = { + locations: PropTypes.array.isRequired, +}; + +export default CSSModules(MapHistory, styles); diff --git a/src/components/MapHistory/MapHistory.scss b/src/components/MapHistory/MapHistory.scss new file mode 100644 index 0000000..317e61e --- /dev/null +++ b/src/components/MapHistory/MapHistory.scss @@ -0,0 +1,39 @@ +.history-wrap{ + width: 100%; + height: 100%; + position: absolute; +} +.map-history{ + width: 100%; + height: 100%; + position: absolute; +} +.no-history{ + width: 100%; + height: 100%; + font-size: 24px; + text-align: center; + background-color: #FFF; + display: flex; + align-items: center; + justify-content: center; +} +.history-toolbar{ + position: absolute; + bottom:20px; + left:0; + right:0; + margin:0 auto; + width:520px; + height: 80px; + background-color: #FFF; + .slider{ + padding: 10px 20px; + } + .info{ + text-align: center; + strong{ + font-weight: 600; + } + } +} diff --git a/src/components/MapHistory/index.js b/src/components/MapHistory/index.js new file mode 100644 index 0000000..85f8948 --- /dev/null +++ b/src/components/MapHistory/index.js @@ -0,0 +1,3 @@ +import MapHistory from './MapHistory'; + +export default MapHistory; diff --git a/src/components/NoFlyZone/NoFlyZone.jsx b/src/components/NoFlyZone/NoFlyZone.jsx new file mode 100644 index 0000000..e9b296a --- /dev/null +++ b/src/components/NoFlyZone/NoFlyZone.jsx @@ -0,0 +1,30 @@ +import React, {PropTypes} from 'react'; +import {Circle, Polygon} from 'react-google-maps'; + +export const NoFlyZone = ({zone}) => { + if (zone.circle) { + return ( + + ); + } + + return ( + ({lng: pair[0], lat: pair[1]}))} + /> + ); +}; + +NoFlyZone.propTypes = { + zone: PropTypes.object.isRequired, +}; + +export default NoFlyZone; diff --git a/src/components/NoFlyZone/index.js b/src/components/NoFlyZone/index.js new file mode 100644 index 0000000..3dfc2d0 --- /dev/null +++ b/src/components/NoFlyZone/index.js @@ -0,0 +1,3 @@ +import NoFlyZone from './NoFlyZone'; + +export default NoFlyZone; diff --git a/src/components/TextField/TextField.jsx b/src/components/TextField/TextField.jsx index ffc768b..d76fd6f 100644 --- a/src/components/TextField/TextField.jsx +++ b/src/components/TextField/TextField.jsx @@ -11,7 +11,7 @@ export const TextField = (props) => ( ); TextField.propTypes = { - type: PropTypes.oneOf(['text', 'email', 'password']), + type: PropTypes.oneOf(['text', 'email', 'password', 'date']), size: PropTypes.oneOf(['normal', 'narrow']), readOnly: PropTypes.bool, label: PropTypes.string, diff --git a/src/containers/AppContainer.jsx b/src/containers/AppContainer.jsx index 92f964a..cf3598a 100644 --- a/src/containers/AppContainer.jsx +++ b/src/containers/AppContainer.jsx @@ -6,7 +6,7 @@ import ReduxToastr from 'react-redux-toastr'; const AppContainer = ({history, routes, routerKey, store}) => ( -
    +
    } key={routerKey}>{routes} Drone Market - +
    diff --git a/src/layouts/AdminLayout/AdminLayout.jsx b/src/layouts/AdminLayout/AdminLayout.jsx new file mode 100644 index 0000000..3d85b3a --- /dev/null +++ b/src/layouts/AdminLayout/AdminLayout.jsx @@ -0,0 +1,28 @@ +import React, {PropTypes} from 'react'; +import CSSModules from 'react-css-modules'; +import AdminHeader from 'components/AdminHeader'; +import Breadcrumbs from 'react-breadcrumbs'; +import Footer from 'components/Footer'; +import styles from './AdminLayout.scss'; + +export const AdminLayout = ({children, routes, params}) => ( +
    + +
    + +
    + +
    + {children} +
    +
    +
    +); + +AdminLayout.propTypes = { + children: PropTypes.any.isRequired, + routes: PropTypes.any.isRequired, + params: PropTypes.any.isRequired, +}; + +export default CSSModules(AdminLayout, styles); diff --git a/src/layouts/AdminLayout/AdminLayout.scss b/src/layouts/AdminLayout/AdminLayout.scss new file mode 100644 index 0000000..4db0483 --- /dev/null +++ b/src/layouts/AdminLayout/AdminLayout.scss @@ -0,0 +1,7 @@ +.admin-layout { + composes: core-layout from '../CoreLayout/CoreLayout.scss'; +} + +.content { + composes: content from '../CoreLayout/CoreLayout.scss'; +} diff --git a/src/layouts/AdminLayout/index.js b/src/layouts/AdminLayout/index.js new file mode 100644 index 0000000..1be1442 --- /dev/null +++ b/src/layouts/AdminLayout/index.js @@ -0,0 +1,3 @@ +import AdminLayout from './AdminLayout'; + +export default AdminLayout; diff --git a/src/routes/Admin/AdminDashboard/components/AdminDashboardView.jsx b/src/routes/Admin/AdminDashboard/components/AdminDashboardView.jsx new file mode 100644 index 0000000..76ec91e --- /dev/null +++ b/src/routes/Admin/AdminDashboard/components/AdminDashboardView.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import CSSModules from 'react-css-modules'; +import styles from './AdminDashboardView.scss'; + +export const AdminDashboardView = () => ( +
    + Placeholder for admin dashboard +
    +); + +export default CSSModules(AdminDashboardView, styles); diff --git a/src/routes/Admin/AdminDashboard/components/AdminDashboardView.scss b/src/routes/Admin/AdminDashboard/components/AdminDashboardView.scss new file mode 100644 index 0000000..34de8d1 --- /dev/null +++ b/src/routes/Admin/AdminDashboard/components/AdminDashboardView.scss @@ -0,0 +1,7 @@ +.admin-dashboard-view { + background-color: transparent; + + :global { + + } +} diff --git a/src/routes/Admin/AdminDashboard/containers/AdminDashboardContainer.js b/src/routes/Admin/AdminDashboard/containers/AdminDashboardContainer.js new file mode 100644 index 0000000..2fed468 --- /dev/null +++ b/src/routes/Admin/AdminDashboard/containers/AdminDashboardContainer.js @@ -0,0 +1,12 @@ +import {asyncConnect} from 'redux-connect'; +import {actions} from '../modules/AdminDashboard'; + +import AdminDashboardView from '../components/AdminDashboardView'; + +const resolve = [{ + promise: () => Promise.resolve(), +}]; + +const mapState = (state) => state.adminDashboard; + +export default asyncConnect(resolve, mapState, actions)(AdminDashboardView); diff --git a/src/routes/Admin/AdminDashboard/index.js b/src/routes/Admin/AdminDashboard/index.js new file mode 100644 index 0000000..5824083 --- /dev/null +++ b/src/routes/Admin/AdminDashboard/index.js @@ -0,0 +1,15 @@ +import {injectReducer} from '../../../store/reducers'; + +export default (store) => ({ + path: 'dashboard', + name: 'Dashboard', + getComponent(nextState, cb) { + require.ensure([], (require) => { + const AdminDashboard = require('./containers/AdminDashboardContainer').default; + const reducer = require('./modules/AdminDashboard').default; + + injectReducer(store, {key: 'adminDashboard', reducer}); + cb(null, AdminDashboard); + }, 'AdminDashboard'); + }, +}); diff --git a/src/routes/Admin/AdminDashboard/modules/AdminDashboard.js b/src/routes/Admin/AdminDashboard/modules/AdminDashboard.js new file mode 100644 index 0000000..aae5a07 --- /dev/null +++ b/src/routes/Admin/AdminDashboard/modules/AdminDashboard.js @@ -0,0 +1,30 @@ +import {createAction, handleActions} from 'redux-actions'; + +// ------------------------------------ +// Constants +// ------------------------------------ +export const SAMPLE = 'AdminDashboard/SAMPLE'; + +// ------------------------------------ +// Actions +// ------------------------------------ + + +export const sample2 = () => async (dispatch, getState) => { + getState(); // to pass eslint from the begining +}; + +export const actions = { + sample: createAction(SAMPLE), + sample2, +}; + +// ------------------------------------ +// Reducer +// ------------------------------------ +export default handleActions({ + [SAMPLE]: (state, {payload}) => { + payload; // to pass eslint from the begining + return state; + }, +}, {}); diff --git a/src/routes/Admin/NoFlyZones/components/NfzList/NfzList.jsx b/src/routes/Admin/NoFlyZones/components/NfzList/NfzList.jsx new file mode 100644 index 0000000..4a81526 --- /dev/null +++ b/src/routes/Admin/NoFlyZones/components/NfzList/NfzList.jsx @@ -0,0 +1,155 @@ +import React, {PropTypes} from 'react'; +import {Row, Col} from 'react-flexbox-grid/lib/index'; +import CSSModules from 'react-css-modules'; +import TextField from 'components/TextField'; +import Select from 'components/Select'; +import Button from 'components/Button'; +import Checkbox from 'components/Checkbox'; +import moment from 'moment'; +import styles from './NfzList.scss'; + +const colors = [ + {label: 'Red', value: 'red'}, + {label: 'Yellow', value: 'yellow'}, + {label: 'Black', value: 'black'}, +]; + +const formatDate = (date) => { + if (!date) { + return ''; + } + return moment(date).format('YYYY-MM-DD'); +}; + +const isValid = (zone) => { + if (!zone.description.length) { + return false; + } + if (!zone.isPermanent) { + if (!zone.startTime || !zone.endTime || !moment(zone.startTime).isValid() || !moment(zone.endTime).isValid()) { + return false; + } + if (moment(zone.startTime).isAfter(zone.endTime)) { + return false; + } + } + return true; +}; + +export const NfzList = ({zones, updateZone, saveNfz, deleteNfz}) => { + const update = (zone, values) => updateZone({...zone, isEdited: true, ...values}); + return ( +
    + + Visible zones: + + + {zones.map((zone) => +
    + + + + Description: + + + + update(zone, {description: e.target.value})} + /> + + + + + + Color: + + + + updateZone({...zone, style: {fillColor: opt.value}})} + /> + + } +
    + + + + + +
    +
    + )} + +
    +); + +Zones.propTypes = { + zones: PropTypes.array.isRequired, + updateZone: PropTypes.func.isRequired, + deleteZone: PropTypes.func.isRequired, +}; + +export default CSSModules(Zones, styles); diff --git a/src/routes/ServiceRequest/components/Zones/Zones.scss b/src/routes/ServiceRequest/components/Zones/Zones.scss new file mode 100644 index 0000000..1c82aae --- /dev/null +++ b/src/routes/ServiceRequest/components/Zones/Zones.scss @@ -0,0 +1,24 @@ +.zones { + background-color: transparent; + + :global { + + } +} + +.label { + line-height: 36px; +} + +.item { + margin-top: 15px; + + + .item { + border-top: 1px solid #ccc; + padding-top: 15px; + } +} + +.actions { + margin-top: 5px; +} diff --git a/src/routes/ServiceRequest/components/Zones/index.js b/src/routes/ServiceRequest/components/Zones/index.js new file mode 100644 index 0000000..f4d2b18 --- /dev/null +++ b/src/routes/ServiceRequest/components/Zones/index.js @@ -0,0 +1,3 @@ +import Zones from './Zones'; + +export default Zones; diff --git a/src/routes/ServiceRequest/containers/ProviderMapContainer.js b/src/routes/ServiceRequest/containers/ProviderMapContainer.js index 299e8f6..c10ce7f 100644 --- a/src/routes/ServiceRequest/containers/ProviderMapContainer.js +++ b/src/routes/ServiceRequest/containers/ProviderMapContainer.js @@ -1,6 +1,7 @@ import {connect} from 'react-redux'; import ProviderMap from '../components/ProviderMap'; +import {actions} from '../modules/ServiceRequest'; const mapState = (state) => state.serviceRequest; -export default connect(mapState, {})(ProviderMap); +export default connect(mapState, actions)(ProviderMap); diff --git a/src/routes/ServiceRequest/modules/ServiceRequest.js b/src/routes/ServiceRequest/modules/ServiceRequest.js index 11908e6..909a9af 100644 --- a/src/routes/ServiceRequest/modules/ServiceRequest.js +++ b/src/routes/ServiceRequest/modules/ServiceRequest.js @@ -1,23 +1,64 @@ -import {handleActions} from 'redux-actions'; +import {handleActions, createAction} from 'redux-actions'; + +// ------------------------------------ +// Constants +// ------------------------------------ +export const ADD_ZONE = 'ServiceRequest/ADD_ZONE'; +export const UPDATE_ZONE = 'ServiceRequest/UPDATE_ZONE'; +export const DELETE_ZONE = 'ServiceRequest/DELETE_ZONE'; // ------------------------------------ // Actions // ------------------------------------ -export const sendRequest = (values) => new Promise((resolve) => { - alert(JSON.stringify(values, null, 2)); +export const sendRequest = (values, dispatch, state) => new Promise((resolve) => { + alert(JSON.stringify({...values, zones: state.zones}, null, 2)); resolve(); }); export const actions = { + addZone: createAction(ADD_ZONE), + updateZone: createAction(UPDATE_ZONE), + deleteZone: createAction(DELETE_ZONE), }; // ------------------------------------ // Reducer // ------------------------------------ export default handleActions({ + [ADD_ZONE]: (state, {payload: {coordinates, point}}) => { + const zone = { + id: new Date().getTime(), + description: 'New Zone', + location: point ? { + type: 'Point', + coordinates: point, + } + : { + type: 'Polygon', + coordinates: [[...coordinates, coordinates[0]]], + }, + style: { + fillColor: 'green', + }, + }; + return {...state, zones: [zone, ...state.zones]}; + }, + [UPDATE_ZONE]: (state, {payload: zone}) => ({ + ...state, + zones: state.zones.map((item) => { + if (item.id === zone.id) { + return zone; + } + return item; + }), + }), + [DELETE_ZONE]: (state, {payload: zone}) => ({ + ...state, + zones: state.zones.filter((item) => item.id !== zone.id), + }), }, { startLocation: { @@ -65,4 +106,5 @@ export default handleActions({ }, ], distance: '8 km', + zones: [], }); diff --git a/src/routes/index.js b/src/routes/index.js index ba238aa..b89d765 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,4 +1,5 @@ import CoreLayout from 'layouts/CoreLayout'; +import AdminLayout from 'layouts/AdminLayout'; import MissionProgressRoute from './MissionProgress'; import ServiceRequestRoute from './ServiceRequest'; import DashboardRoute from './Dashboard'; @@ -18,13 +19,13 @@ import HomeRoute from './Home'; import BrowseProviderRoute from './BrowseProvider'; import DroneDetailsRoute from './DroneDetails'; import AvailablePackagesRoute from './AvailablePackages'; +import AdminDashboard from './Admin/AdminDashboard'; +import NoFlyZones from './Admin/NoFlyZones'; import ProviderDetailsRoute from './ProviderDetails'; export const createRoutes = (store) => ({ path: '/', - name: 'CoreLayout', /* Breadcrumb name */ - staticName: true, - component: CoreLayout, + name: 'CoreLayout', indexRoute: { onEnter: (nextState, replace, cb) => { replace('/dashboard'); @@ -32,28 +33,53 @@ export const createRoutes = (store) => ({ }, }, childRoutes: [ - ServiceRequestRoute(store), - DashboardRoute(store), - MissionList(store), - MissionPlanner(store), - MyRequestRoute(store), - MyRequestStatusRoute(store), - StatusDetailRoute(store), - DronesMapRoute(store), - MissionProgressRoute(store), - EditDataRoute(store), - MyDroneRoute(store), - DroneDetailsRoute(store), - EditDronesRoute(store), - MyServicesRoute(store), - ServiceDetailsRoute(store), - AddServicesRoute(store), - HomeRoute(store), - BrowseProviderRoute(store), - DroneDetailsRoute(store), - AvailablePackagesRoute(store), + // non-admin routes + { + name: 'CoreLayout', /* Breadcrumb name */ + staticName: true, + component: CoreLayout, + childRoutes: [ + ServiceRequestRoute(store), + DashboardRoute(store), + MissionList(store), + MissionPlanner(store), + MyRequestRoute(store), + MyRequestStatusRoute(store), + StatusDetailRoute(store), + DronesMapRoute(store), + MissionProgressRoute(store), + EditDataRoute(store), + MyDroneRoute(store), + DroneDetailsRoute(store), + EditDronesRoute(store), + MyServicesRoute(store), + ServiceDetailsRoute(store), + AddServicesRoute(store), + HomeRoute(store), + BrowseProviderRoute(store), + DroneDetailsRoute(store), + AvailablePackagesRoute(store), + ], + }, ProviderDetailsRoute(store), + // admin routes + { + path: 'admin', + name: 'Admin', + indexRoute: { + onEnter: (nextState, replace, cb) => { + replace('/admin/dashboard'); + cb(); + }, + }, + staticName: true, + component: AdminLayout, + childRoutes: [ + AdminDashboard(store), + NoFlyZones(store), + ], + }, ], }); diff --git a/src/services/APIService.js b/src/services/APIService.js index a69c8d2..783359b 100644 --- a/src/services/APIService.js +++ b/src/services/APIService.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import superagent from 'superagent'; import superagentPromise from 'superagent-promise'; -import config from '../../config/default'; +import config from '../config/index'; // DEMO: emulate API requests with dummy data for demo purposes @@ -467,13 +467,13 @@ const testUser = { }; const register = () => request - .post(`${config.API_BASE_PATH}/api/v1/register`) + .post(`${config.api.basePath}/api/v1/register`) .send(testUser) .set('Content-Type', 'application/json') .end(); const authorize = () => request - .post(`${config.API_BASE_PATH}/api/v1/login`) + .post(`${config.api.basePath}/api/v1/login`) .set('Content-Type', 'application/json') .send(_.pick(testUser, 'email', 'password')) .end(); @@ -505,12 +505,12 @@ export default class APIService { const accessToken = authRes.body.accessToken; return request - .get(`${config.API_BASE_PATH}/api/v1/missions`) + .get(`${config.api.basePath}/api/v1/missions`) .set('Authorization', `Bearer ${accessToken}`) .end() .then((res) => res.body.items.map((item) => ({ ...item, - downloadLink: `${config.API_BASE_PATH}/api/v1/missions/${item.id}/download?token=${accessToken}`, + downloadLink: `${config.api.basePath}/api/v1/missions/${item.id}/download?token=${accessToken}`, }))); }); } @@ -520,7 +520,7 @@ export default class APIService { const accessToken = authRes.body.accessToken; return request - .get(`${config.API_BASE_PATH}/api/v1/missions/${id}`) + .get(`${config.api.basePath}/api/v1/missions/${id}`) .set('Authorization', `Bearer ${accessToken}`) .end() .then((res) => res.body); @@ -532,7 +532,7 @@ export default class APIService { const accessToken = authRes.body.accessToken; return request - .post(`${config.API_BASE_PATH}/api/v1/missions`) + .post(`${config.api.basePath}/api/v1/missions`) .set('Authorization', `Bearer ${accessToken}`) .send(values) .end() @@ -545,7 +545,7 @@ export default class APIService { const accessToken = authRes.body.accessToken; return request - .put(`${config.API_BASE_PATH}/api/v1/missions/${id}`) + .put(`${config.api.basePath}/api/v1/missions/${id}`) .set('Authorization', `Bearer ${accessToken}`) .send(values) .end() @@ -558,7 +558,7 @@ export default class APIService { const accessToken = authRes.body.accessToken; return request - .del(`${config.API_BASE_PATH}/api/v1/missions/${id}`) + .del(`${config.api.basePath}/api/v1/missions/${id}`) .set('Authorization', `Bearer ${accessToken}`) .end() .then((res) => res.body); @@ -574,8 +574,71 @@ export default class APIService { */ static searchDrones(params) { return request - .get(`${config.API_BASE_PATH}/api/v1/drones`) + .get(`${config.api.basePath}/api/v1/drones`) .query(params) .end(); } + + /** + * get location history of drone + * @param {String} id id of drone + * @param {Number} limit limit to search + * @returns {{total: Number, items: Array}} the result + */ + static getLocations(id, limit) { + return request.get(`${config.api.basePath}/api/v1/droneposition/${id}`).query({limit}).end(); + } + + /** + * Search nfz + * @param {Object} params + * @param {Number} params.limit the limit + * @param {Number} params.offset the offset + * @param {Object} params.geometry the view geometry + * @returns {{total: Number, items: Array}} the result + */ + static searchNfz(params) { + return request + .post(`${config.api.basePath}/api/v1/nfz/search`) + .send(params) + .end() + .then((res) => res.body); + } + + /** + * Create nfz + * @param {Object} params + * @returns {Object} the created nfz + */ + static createNfz(params) { + return request + .post(`${config.api.basePath}/api/v1/nfz`) + .send(params) + .end() + .then((res) => res.body); + } + + /** + * Update nfz + * @param {Number} id + * @param {Object} params + * @returns {Object} the updated nfz + */ + static updateNfz(id, params) { + return request + .put(`${config.api.basePath}/api/v1/nfz/${id}`) + .send(params) + .end() + .then((res) => res.body); + } + + /** + * Delete nfz + * @param {Number} id + */ + static deleteNfz(id) { + return request + .del(`${config.api.basePath}/api/v1/nfz/${id}`) + .end(); + } } diff --git a/src/store/modules/searchNFZ.js b/src/store/modules/searchNFZ.js new file mode 100644 index 0000000..43f79e9 --- /dev/null +++ b/src/store/modules/searchNFZ.js @@ -0,0 +1,53 @@ +import {handleActions} from 'redux-actions'; +import APIService from 'services/APIService'; + +export const MAX_DRONES = 1000; + +// ------------------------------------ +// Constants +// ------------------------------------ +export const LOAD_NFZ = 'searchNFZ/LOAD_NFZ'; + +// ------------------------------------ +// Actions +// ------------------------------------ + +// map google maps bounds to polygon format accepted by API +export const mapsBoundsToPolygon = (bounds) => [[ + [bounds.west, bounds.north], + [bounds.east, bounds.north], + [bounds.east, bounds.south], + [bounds.west, bounds.south], + [bounds.west, bounds.north], +]]; + +// load zones based on current google maps bounds +export const loadNfz = (bounds) => async (dispatch) => { + const coordinates = mapsBoundsToPolygon(bounds); + const {items: zones} = await APIService.searchNfz({ + offset: 0, + limit: MAX_DRONES, + isActive: true, + matchTime: true, + geometry: { + type: 'Polygon', + coordinates, + }, + }); + dispatch({type: LOAD_NFZ, payload: zones}); +}; + +export const actions = { + loadNfz, +}; + +// ------------------------------------ +// Reducer +// ------------------------------------ + + +export default handleActions({ + [LOAD_NFZ]: (state, {payload: zones}) => ({noFlyZones: zones}), +}, { + noFlyZones: [], +}); diff --git a/src/store/reducers.js b/src/store/reducers.js index 74c68bc..7b0c5a5 100644 --- a/src/store/reducers.js +++ b/src/store/reducers.js @@ -4,10 +4,12 @@ import {reducer as reduxAsyncConnect} from 'redux-connect'; import {reducer as form} from 'redux-form'; import {reducer as toastr} from 'react-redux-toastr'; import global from './modules/global'; +import searchNFZ from './modules/searchNFZ'; export const makeRootReducer = (asyncReducers) => combineReducers({ router, global, + searchNFZ, form, reduxAsyncConnect, ...asyncReducers, diff --git a/webpack.config.js b/webpack.config.js index fd8f63f..7da3895 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,8 +2,9 @@ const path = require('path'); const _ = require('lodash'); -const ip = require('ip'); const webpack = require('webpack'); +const Dotenv = require('dotenv-webpack'); + const config = require('config'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); @@ -95,13 +96,12 @@ module.exports = { publicPath: '/', }, plugins: [ + new Dotenv({ + path: '.env', // if not simply .env + safe: true, // lets load the .env.example file as well + }), new webpack.DefinePlugin({ __COVERAGE__: !argv.watch && process.env.NODE_ENV === 'test', - 'process.env': { - NODE_ENV: JSON.stringify(process.env.NODE_ENV), - GOOGLE_API_KEY: JSON.stringify(process.env.GOOGLE_API_KEY), - API_BASE_URL: JSON.stringify(process.env.API_BASE_URL), - }, }), new HtmlWebpackPlugin({ GOOGLE_API_KEY: config.GOOGLE_API_KEY, @@ -160,6 +160,11 @@ module.exports = { loaders: ['style', 'css?modules'], include: /flexboxgrid/, }), + fixStyleLoader({ + test: /\.css$/, + loaders: ['style', 'css'], + include: /rc-slider/, + }), { test: /\.woff(\?.*)?$/, loader: 'url?prefix=fonts/&name=[path][name].[ext]&limit=10000&mimetype=application/font-woff',