diff options
author | Anton Tananaev <anton.tananaev@gmail.com> | 2020-03-22 23:07:16 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-03-22 23:07:16 -0700 |
commit | 48a726021f5d3c741749094891d529ccb3ba59b4 (patch) | |
tree | 8df80eca54f9dd39664f63365ffcc2ec248fb3df | |
parent | f5165c8e897e8d9cf4219d943e2d34b61adb48b5 (diff) | |
parent | ba9cc86f667486a09edb323402c2d63ada5ea639 (diff) | |
download | trackermap-web-48a726021f5d3c741749094891d529ccb3ba59b4.tar.gz trackermap-web-48a726021f5d3c741749094891d529ccb3ba59b4.tar.bz2 trackermap-web-48a726021f5d3c741749094891d529ccb3ba59b4.zip |
Merge pull request #768 from traccar/modern
Create a new React web app
-rw-r--r-- | modern/.env | 1 | ||||
-rw-r--r-- | modern/.gitignore | 21 | ||||
-rw-r--r-- | modern/.vscode/launch.json | 15 | ||||
-rw-r--r-- | modern/README.md | 11 | ||||
-rw-r--r-- | modern/package.json | 38 | ||||
-rw-r--r-- | modern/public/favicon.ico | bin | 0 -> 15086 bytes | |||
-rw-r--r-- | modern/public/images/background.svg | 10 | ||||
-rw-r--r-- | modern/public/images/icon/marker.svg | 2 | ||||
-rw-r--r-- | modern/public/index.html | 17 | ||||
-rw-r--r-- | modern/public/logo.svg | 33 | ||||
-rw-r--r-- | modern/public/manifest.json | 15 | ||||
-rw-r--r-- | modern/src/App.js | 21 | ||||
-rw-r--r-- | modern/src/DeviceList.js | 52 | ||||
-rw-r--r-- | modern/src/LoginPage.js | 148 | ||||
-rw-r--r-- | modern/src/MainMap.js | 172 | ||||
-rw-r--r-- | modern/src/MainPage.js | 91 | ||||
-rw-r--r-- | modern/src/MainToolbar.js | 123 | ||||
-rw-r--r-- | modern/src/SocketController.js | 61 | ||||
-rw-r--r-- | modern/src/actions/index.js | 19 | ||||
-rw-r--r-- | modern/src/index.js | 21 | ||||
-rw-r--r-- | modern/src/reducers/index.js | 35 | ||||
-rw-r--r-- | modern/src/registerServiceWorker.js | 117 | ||||
-rw-r--r-- | modern/src/setupProxy.js | 6 |
23 files changed, 1029 insertions, 0 deletions
diff --git a/modern/.env b/modern/.env new file mode 100644 index 00000000..6f809cc2 --- /dev/null +++ b/modern/.env @@ -0,0 +1 @@ +SKIP_PREFLIGHT_CHECK=true diff --git a/modern/.gitignore b/modern/.gitignore new file mode 100644 index 00000000..d30f40ef --- /dev/null +++ b/modern/.gitignore @@ -0,0 +1,21 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/modern/.vscode/launch.json b/modern/.vscode/launch.json new file mode 100644 index 00000000..8bd51ee6 --- /dev/null +++ b/modern/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Chrome", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/src", + "sourceMapPathOverrides": { + "webpack:///src/*": "${webRoot}/*" + } + } + ] +} diff --git a/modern/README.md b/modern/README.md new file mode 100644 index 00000000..6b8edbb3 --- /dev/null +++ b/modern/README.md @@ -0,0 +1,11 @@ +This is a new version of the Traccar web app. It is still in a very early stage of development. + +It uses [React](https://reactjs.org/), [Material UI](https://material-ui.com/) and [OpenLayers](https://openlayers.org/). Feedback and contributions are welcome. + +To run the project in development mode: + +- Make sure you have Traccar back-end running locally on port 8082 +- Install dependencies using `npm install` command +- Run development server using `npm start` command + +Project was created using [Create React App](https://github.com/facebook/create-react-app). For more information see [user guide](https://github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md). diff --git a/modern/package.json b/modern/package.json new file mode 100644 index 00000000..04c1b4aa --- /dev/null +++ b/modern/package.json @@ -0,0 +1,38 @@ +{ + "name": "traccar", + "version": "0.1.0", + "private": true, + "dependencies": { + "@material-ui/core": "^4.9.7", + "@material-ui/icons": "^4.9.1", + "mapbox-gl": "^1.8.1", + "ol": "^6.2.1", + "ol-mapbox-style": "^6.0.1", + "react": "^16.13.0", + "react-container-dimensions": "^1.4.1", + "react-dom": "^16.13.0", + "react-redux": "^7.2.0", + "react-router-dom": "^5.1.2", + "react-scripts": "^3.4.0", + "redux": "^4.0.5", + "typeface-roboto": "0.0.75" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/modern/public/favicon.ico b/modern/public/favicon.ico Binary files differnew file mode 100644 index 00000000..6be99dda --- /dev/null +++ b/modern/public/favicon.ico diff --git a/modern/public/images/background.svg b/modern/public/images/background.svg new file mode 100644 index 00000000..3dcb6870 --- /dev/null +++ b/modern/public/images/background.svg @@ -0,0 +1,10 @@ +<svg width="48px" height="48px" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <defs> + <filter id="shadow"> + <feDropShadow dx="0" dy="0.1" stdDeviation="0.3" flood-color="grey"/> + </filter> + </defs> + + <circle cx="5" cy="50%" r="4" + style="fill:white; filter:url(#shadow);"/> +</svg> diff --git a/modern/public/images/icon/marker.svg b/modern/public/images/icon/marker.svg new file mode 100644 index 00000000..f626547c --- /dev/null +++ b/modern/public/images/icon/marker.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg width="24px" height="24px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M 12 2.0097656 C 8.144 2.0097656 5.0078125 5.1479063 5.0078125 9.0039062 C 5.0078125 13.486906 10.974516 20.769172 11.228516 21.076172 L 12 22.011719 L 12.771484 21.076172 C 13.025484 20.768172 18.992188 13.486906 18.992188 9.0039062 C 18.992187 5.1469062 15.856 2.0097656 12 2.0097656 z M 12 4.0097656 C 14.753 4.0097656 16.992188 6.2509062 16.992188 9.0039062 C 16.992187 11.708906 13.878 16.361172 12 18.826172 C 10.122 16.363172 7.0078125 11.712906 7.0078125 9.0039062 C 7.0078125 6.2509062 9.247 4.0097656 12 4.0097656 z M 12 6.5 C 10.619 6.5 9.5 7.619 9.5 9 C 9.5 10.381 10.619 11.5 12 11.5 C 13.381 11.5 14.5 10.381 14.5 9 C 14.5 7.619 13.381 6.5 12 6.5 z" fill="#000000"/></svg> diff --git a/modern/public/index.html b/modern/public/index.html new file mode 100644 index 00000000..29800eb2 --- /dev/null +++ b/modern/public/index.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" /> + <meta name="theme-color" content="#000000"> + <link rel="manifest" href="%PUBLIC_URL%/manifest.json"> + <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> + <title>Traccar</title> + </head> + <body style="margin: 0; padding: 0;"> + <noscript> + You need to enable JavaScript to run this app. + </noscript> + <div id="root"></div> + </body> +</html> diff --git a/modern/public/logo.svg b/modern/public/logo.svg new file mode 100644 index 00000000..008b46d4 --- /dev/null +++ b/modern/public/logo.svg @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> +<svg id="svg2985" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" viewBox="0 0 240 64" width="240" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <metadata id="metadata2990"> + <rdf:RDF> + <cc:Work rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> + <dc:title/> + </cc:Work> + </rdf:RDF> + </metadata> + <g id="layer1"> + <rect id="rect3778" height="64" width="236.1" y="0" x="0" fill="none"/> + <ellipse id="path3038" rx="28.995" ry="28.995" transform="rotate(-30)" cy="43.713" cx="11.713" stroke-width="10.699" fill="#fff"/> + <g fill="#336"> + <circle id="path2993" stroke-width="1.3262" transform="rotate(-30)" cy="43.713" cx="9.4364" r="2.2765"/> + <path id="path3004" d="m37.012 24.177-2.8428 3.6128c0.66345 0.52205 1.3255 1.1576 1.7734 1.9333 0.4479 0.77578 0.66726 1.6669 0.78764 2.5025l4.5502-0.65558c-0.193-1.42-0.633-2.804-1.394-4.123s-1.74-2.391-2.874-3.27z" stroke-width="1.0095"/> + <path id="path3014" d="m42.504 16.9-2.8428 3.6128c1.607 1.2355 3.0914 2.7935 4.1679 4.6581s1.6835 3.9291 1.95 5.9386l4.5502-0.65558c-0.33967-2.5954-1.1669-5.1513-2.5573-7.5594-1.3903-2.4081-3.1901-4.4025-5.268-5.9944z" stroke-width="1.0095"/> + <path id="path3036" d="m2.607 52.819a9.1058 9.1058 0 0 1 -7.8859 -4.5529 9.1058 9.1058 0 0 1 0 -9.1058 9.1058 9.1058 0 0 1 7.8859 -4.5529l-2e-7 9.1058z" transform="rotate(-30)" stroke-width="3.6204"/> + <path id="path3038-8" d="m17.502 6.8895c-13.868 8.0065-18.619 25.74-10.612 39.608 8.006 13.868 25.739 18.619 39.608 10.613 13.868-8.007 18.619-25.74 10.613-39.609-8.007-13.868-25.74-18.619-39.609-10.612zm1.706 2.9541c12.237-7.0648 27.884-2.8722 34.948 9.3644 7.065 12.237 2.873 27.884-9.364 34.948-12.237 7.065-27.884 2.873-34.948-9.364-7.0652-12.237-2.8726-27.884 9.364-34.948z" stroke-width="1.0095"/> + <g id="text3003" aria-label="Traccar"> + <path id="path4172" d="m89.719 48.671h-3.915v-30.192h-10.663v-3.4775h25.241v3.4775h-10.663v30.192z"/> + <path id="path4174" d="m116.36 22.969q1.6812 0 3.0169 0.27636l-0.52968 3.5466q-1.566-0.34544-2.7636-0.34544-3.063 0-5.2508 2.4872-2.1648 2.4872-2.1648 6.195v13.541h-3.8229v-25.241h3.1551l0.43756 4.675h0.18424q1.4048-2.4642 3.3854-3.7999t4.3526-1.3357z"/> + <path id="path4176" d="m139.62 48.671-0.75998-3.5926h-0.18424q-1.8884 2.3721-3.7769 3.2242-1.8654 0.82907-4.675 0.82907-3.7538 0-5.8956-1.9345-2.1187-1.9345-2.1187-5.5041 0-7.6459 12.229-8.0143l4.2835-0.13818v-1.566q0-2.9708-1.2897-4.3756-1.2666-1.4278-4.0763-1.4278-3.1551 0-7.1392 1.9345l-1.1745-2.9248q1.8654-1.0133 4.0762-1.589 2.2339-0.57574 4.4678-0.57574 4.5138 0 6.6786 2.0036 2.1878 2.0036 2.1878 6.4253v17.226h-2.8326zm-8.6361-2.6945q3.5696 0 5.5962-1.9575 2.0496-1.9575 2.0496-5.4811v-2.2799l-3.8229 0.16121q-4.5599 0.16121-6.5865 1.4278-2.0036 1.2436-2.0036 3.892 0 2.0727 1.2436 3.1551 1.2666 1.0824 3.5236 1.0824z"/> + <path id="path4178" d="m160.44 49.131q-5.4811 0-8.498-3.3623-2.9939-3.3854-2.9939-9.5573 0-6.3332 3.0399-9.7876 3.063-3.4545 8.7052-3.4545 1.8194 0 3.6387 0.3915t2.8557 0.92119l-1.1745 3.2472q-1.2666-0.50665-2.7636-0.82907-1.4969-0.34544-2.6484-0.34544-7.6919 0-7.6919 9.8106 0 4.652 1.8654 7.1392 1.8884 2.4872 5.5732 2.4872 3.1551 0 6.4713-1.3588v3.3854q-2.5333 1.3127-6.3792 1.3127z"/> + <path id="path4180" d="m182.92 49.131q-5.4811 0-8.498-3.3623-2.9939-3.3854-2.9939-9.5573 0-6.3332 3.0399-9.7876 3.063-3.4545 8.7052-3.4545 1.8193 0 3.6387 0.3915t2.8557 0.92119l-1.1745 3.2472q-1.2666-0.50665-2.7636-0.82907-1.4969-0.34544-2.6484-0.34544-7.6919 0-7.6919 9.8106 0 4.652 1.8654 7.1392 1.8884 2.4872 5.5732 2.4872 3.1551 0 6.4714-1.3588v3.3854q-2.5333 1.3127-6.3792 1.3127z"/> + <path id="path4182" d="m210.83 48.671-0.75998-3.5926h-0.18424q-1.8884 2.3721-3.7769 3.2242-1.8654 0.82907-4.675 0.82907-3.7538 0-5.8956-1.9345-2.1187-1.9345-2.1187-5.5041 0-7.6459 12.229-8.0143l4.2835-0.13818v-1.566q0-2.9708-1.2897-4.3756-1.2666-1.4278-4.0762-1.4278-3.1551 0-7.1392 1.9345l-1.1745-2.9248q1.8654-1.0133 4.0762-1.589 2.2339-0.57574 4.4678-0.57574 4.5138 0 6.6786 2.0036 2.1878 2.0036 2.1878 6.4253v17.226h-2.8326zm-8.6361-2.6945q3.5696 0 5.5962-1.9575 2.0496-1.9575 2.0496-5.4811v-2.2799l-3.8229 0.16121q-4.5599 0.16121-6.5865 1.4278-2.0036 1.2436-2.0036 3.892 0 2.0727 1.2436 3.1551 1.2666 1.0824 3.5235 1.0824z"/> + <path id="path4184" d="m233.08 22.969q1.6812 0 3.0169 0.27636l-0.52968 3.5466q-1.566-0.34544-2.7636-0.34544-3.0629 0-5.2508 2.4872-2.1648 2.4872-2.1648 6.195v13.541h-3.8229v-25.241h3.1551l0.43757 4.675h0.18423q1.4048-2.4642 3.3854-3.7999t4.3526-1.3357z"/> + </g> + </g> + </g> +</svg> diff --git a/modern/public/manifest.json b/modern/public/manifest.json new file mode 100644 index 00000000..350ef8ad --- /dev/null +++ b/modern/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Traccar", + "name": "Traccar GPS Tracking System", + "icons": [ + { + "src": "favicon.ico", + "sizes": "48x48 32x32 16x16", + "type": "image/x-icon" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/modern/src/App.js b/modern/src/App.js new file mode 100644 index 00000000..31b8b69b --- /dev/null +++ b/modern/src/App.js @@ -0,0 +1,21 @@ +import React, { Component, Fragment } from 'react'; +import { Switch, Route } from 'react-router-dom' +import CssBaseline from '@material-ui/core/CssBaseline'; +import MainPage from './MainPage'; +import LoginPage from './LoginPage'; + +class App extends Component { + render() { + return ( + <Fragment> + <CssBaseline /> + <Switch> + <Route exact path='/' component={MainPage} /> + <Route exact path='/login' component={LoginPage} /> + </Switch> + </Fragment> + ); + } +} + +export default App; diff --git a/modern/src/DeviceList.js b/modern/src/DeviceList.js new file mode 100644 index 00000000..03c5126c --- /dev/null +++ b/modern/src/DeviceList.js @@ -0,0 +1,52 @@ +import React, { Component, Fragment } from 'react'; +import { connect } from 'react-redux'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemAvatar from '@material-ui/core/ListItemAvatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import Avatar from '@material-ui/core/Avatar'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import IconButton from '@material-ui/core/IconButton'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Divider from '@material-ui/core/Divider'; +import { selectDevice } from './actions'; + +const mapStateToProps = state => ({ + devices: Array.from(state.devices.values()) +}); + +class DeviceList extends Component { + handleClick(device) { + this.props.dispatch(selectDevice(device)); + } + + render() { + const devices = this.props.devices.map((device, index, list) => + <Fragment key={device.id.toString()}> + <ListItem button onClick={(e) => this.handleClick(device)}> + <ListItemAvatar> + <Avatar> + <LocationOnIcon /> + </Avatar> + </ListItemAvatar> + <ListItemText primary={device.name} secondary={device.uniqueId} /> + <ListItemSecondaryAction> + <IconButton> + <MoreVertIcon /> + </IconButton> + </ListItemSecondaryAction> + </ListItem> + {index < list.length - 1 ? <Divider /> : null} + </Fragment> + ); + + return ( + <List> + {devices} + </List> + ); + } +} + +export default connect(mapStateToProps)(DeviceList); diff --git a/modern/src/LoginPage.js b/modern/src/LoginPage.js new file mode 100644 index 00000000..c094fa3b --- /dev/null +++ b/modern/src/LoginPage.js @@ -0,0 +1,148 @@ +import React, { Component } from 'react'; +import Button from '@material-ui/core/Button'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import Paper from '@material-ui/core/Paper'; +import withStyles from '@material-ui/core/styles/withStyles'; + +const styles = theme => ({ + root: { + width: 'auto', + display: 'block', // Fix IE11 issue. + marginLeft: theme.spacing(3), + marginRight: theme.spacing(3), + [theme.breakpoints.up(400 + theme.spacing(3 * 2))]: { + width: 400, + marginLeft: 'auto', + marginRight: 'auto', + }, + }, + paper: { + marginTop: theme.spacing(8), + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: `${theme.spacing(3)}px`, + }, + logo: { + margin: `${theme.spacing(2)}px 0 ${theme.spacing(1)}px` + }, + buttons: { + width: '100%', + display: 'flex', + flexDirection: 'row' + }, + button: { + flex: '1 1 0', + margin: `${theme.spacing(3)}px ${theme.spacing(1)}px 0` + }, +}); + +class LoginPage extends Component { + constructor(props) { + super(props); + this.state = { + filled: false, + loading: false, + failed: false, + email: "", + password: "" + }; + this.handleChange = this.handleChange.bind(this); + this.handleRegister = this.handleRegister.bind(this); + this.handleLogin = this.handleLogin.bind(this); + } + + handleChange(event) { + this.setState({ + [event.target.id]: event.target.value + }); + } + + handleRegister() { + // TODO implement registration + } + + handleLogin(event) { + event.preventDefault(); + const { email, password } = this.state; + fetch("/api/session", { + method: "POST", + body: new URLSearchParams(`email=${email}&password=${password}`) + }).then(response => { + if (response.ok) { + this.props.history.push('/'); // TODO avoid calling sessions twice + } else { + this.setState({ + failed: true, + password: "" + }); + } + }); + } + + render() { + const { classes } = this.props; + const { failed, email, password } = this.state; + return ( + <main className={classes.root}> + <Paper className={classes.paper}> + + <img className={classes.logo} src="/logo.svg" alt="Traccar" /> + + <form onSubmit={this.handleLogin}> + + <FormControl margin="normal" required fullWidth error={failed}> + <InputLabel htmlFor="email">Email</InputLabel> + <Input + id="email" + value={email} + autoComplete="email" + autoFocus + onChange={this.handleChange} /> + { failed && <FormHelperText>Invalid username or password</FormHelperText> } + </FormControl> + + <FormControl margin="normal" required fullWidth> + <InputLabel htmlFor="password">Password</InputLabel> + <Input + id="password" + type="password" + value={password} + autoComplete="current-password" + onChange={this.handleChange} /> + </FormControl> + + <div className={classes.buttons}> + + <Button + type="button" + variant="contained" + disabled + className={classes.button} + onClick={this.handleRegister}> + Register + </Button> + + <Button + type="submit" + variant="contained" + color="primary" + disabled={!email || !password} + className={classes.button}> + Login + </Button> + + </div> + + </form> + + </Paper> + </main> + ); + } +} + +export default withStyles(styles)(LoginPage); diff --git a/modern/src/MainMap.js b/modern/src/MainMap.js new file mode 100644 index 00000000..35b933b4 --- /dev/null +++ b/modern/src/MainMap.js @@ -0,0 +1,172 @@ +import 'mapbox-gl/dist/mapbox-gl.css'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import mapboxgl from 'mapbox-gl'; + +const calculateMapCenter = (state) => { + if (state.selectedDevice) { + const position = state.positions.get(state.selectedDevice); + if (position) { + return [position.longitude, position.latitude]; + } + } + return null; +} + +const mapFeatureProperties = (state, position) => { + return { + name: state.devices.get(position.deviceId).name + } +} + +const mapStateToProps = state => ({ + mapCenter: calculateMapCenter(state), + data: { + type: 'FeatureCollection', + features: Array.from(state.positions.values()).map(position => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [position.longitude, position.latitude] + }, + properties: mapFeatureProperties(state, position) + })) + } +}); + +class MainMap extends Component { + componentDidMount() { + const map = new mapboxgl.Map({ + container: this.mapContainer, + style: 'https://cdn.traccar.com/map/basic.json', + center: [0, 0], + zoom: 1 + }); + + map.on('load', () => this.mapDidLoad(map)); + } + + loadImage(key, url) { + return new Promise(resolutionFunc => { + const image = new Image(); + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = image.width * window.devicePixelRatio; + canvas.height = image.height * window.devicePixelRatio; + canvas.style.width = `${image.width}px`; + canvas.style.height = `${image.height}px`; + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + this.map.addImage(key, context.getImageData(0, 0, canvas.width, canvas.height), { + pixelRatio: window.devicePixelRatio + }); + resolutionFunc() + } + image.src = url; + }); + } + + mapDidLoad(map) { + this.map = map; + + Promise.all([ + this.loadImage('background', 'images/background.svg'), + this.loadImage('icon-marker', 'images/icon/marker.svg') + ]).then(() => { + this.imagesDidLoad(); + }); + } + + imagesDidLoad() { + this.map.addSource('positions', { + 'type': 'geojson', + 'data': this.props.data + }); + + this.map.addLayer({ + 'id': 'device-background', + 'type': 'symbol', + 'source': 'positions', + 'layout': { + 'icon-image': 'background', + 'icon-allow-overlap': true, + 'text-field': '{name}', + 'text-allow-overlap': true, + 'text-anchor': 'bottom', + 'text-offset': [0, -2], + 'text-font': ['Roboto Regular'], + 'text-size': 12 + }, + 'paint':{ + 'text-halo-color': 'white', + 'text-halo-width': 1 + } + }); + + this.map.addLayer({ + 'id': 'device-icon', + 'type': 'symbol', + 'source': 'positions', + 'layout': { + 'icon-image': 'icon-marker', + 'icon-allow-overlap': true + } + }); + + this.map.addControl(new mapboxgl.NavigationControl()); + + this.map.fitBounds(this.calculateBounds(), { + padding: 100, + maxZoom: 9 + }); + } + + calculateBounds() { + if (this.props.data.features) { + const first = this.props.data.features[0].geometry.coordinates; + const bounds = [[...first], [...first]]; + for (let feature of this.props.data.features) { + const longitude = feature.geometry.coordinates[0] + const latitude = feature.geometry.coordinates[1] + if (longitude < bounds[0][0]) { + bounds[0][0] = longitude; + } else if (longitude > bounds[1][0]) { + bounds[1][0] = longitude; + } + if (latitude < bounds[0][1]) { + bounds[0][1] = latitude; + } else if (latitude > bounds[1][1]) { + bounds[1][1] = latitude; + } + } + return bounds; + } else { + return [[0, 0], [0, 0]]; + } + } + + componentDidUpdate(prevProps) { + if (this.map) { + if (prevProps.mapCenter !== this.props.mapCenter) { + this.map.easeTo({ + center: this.props.mapCenter + }); + } + if (prevProps.data.features !== this.props.data.features) { + this.map.getSource('positions').setData(this.props.data); + } + } + } + + render() { + const style = { + position: 'relative', + overflow: 'hidden', + width: '100%', + height: '100%' + }; + return <div style={style} ref={el => this.mapContainer = el} />; + } +} + +export default connect(mapStateToProps)(MainMap); diff --git a/modern/src/MainPage.js b/modern/src/MainPage.js new file mode 100644 index 00000000..450a5e00 --- /dev/null +++ b/modern/src/MainPage.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import ContainerDimensions from 'react-container-dimensions'; +import MainToobar from './MainToolbar'; +import MainMap from './MainMap'; +import Drawer from '@material-ui/core/Drawer'; +import withStyles from '@material-ui/core/styles/withStyles'; +import SocketController from './SocketController'; +import withWidth, { isWidthUp } from '@material-ui/core/withWidth'; +import DeviceList from './DeviceList'; + +const styles = theme => ({ + root: { + height: "100vh", + display: "flex", + flexDirection: "column" + }, + content: { + flexGrow: 1, + overflow: "hidden", + display: "flex", + flexDirection: "row", + [theme.breakpoints.down('xs')]: { + flexDirection: "column-reverse" + } + }, + drawerPaper: { + position: 'relative', + [theme.breakpoints.up('sm')]: { + width: 350 + }, + [theme.breakpoints.down('xs')]: { + height: 250 + } + }, + mapContainer: { + flexGrow: 1 + } +}); + +class MainPage extends Component { + constructor(props) { + super(props); + this.state = { + loading: true + }; + } + + componentDidMount() { + fetch('/api/session').then(response => { + if (response.ok) { + this.setState({ + loading: false + }); + } else { + this.props.history.push('/login'); + } + }); + } + + render() { + const { classes } = this.props; + const { loading } = this.state; + if (loading) { + return ( + <div>Loading...</div> + ); + } else { + return ( + <div className={classes.root}> + <SocketController /> + <MainToobar history={this.props.history} /> + <div className={classes.content}> + <Drawer + anchor={isWidthUp('sm', this.props.width) ? "left" : "bottom"} + variant="permanent" + classes={{ paper: classes.drawerPaper }}> + <DeviceList /> + </Drawer> + <div className={classes.mapContainer}> + <ContainerDimensions> + <MainMap/> + </ContainerDimensions> + </div> + </div> + </div> + ); + } + } +} + +export default withWidth()(withStyles(styles)(MainPage)); diff --git a/modern/src/MainToolbar.js b/modern/src/MainToolbar.js new file mode 100644 index 00000000..6e2e4d6d --- /dev/null +++ b/modern/src/MainToolbar.js @@ -0,0 +1,123 @@ +import React, { Component, Fragment } from 'react'; +import { withStyles } from '@material-ui/core/styles'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import MenuIcon from '@material-ui/icons/Menu'; +import Drawer from '@material-ui/core/Drawer'; +import List from '@material-ui/core/List'; +import Divider from '@material-ui/core/Divider'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import BarChartIcon from '@material-ui/icons/BarChart'; +import SettingsIcon from '@material-ui/icons/Settings'; + +const styles = theme => ({ + flex: { + flexGrow: 1 + }, + appBar: { + zIndex: theme.zIndex.drawer + 1, + }, + list: { + width: 250 + }, + menuButton: { + marginLeft: -12, + marginRight: 20, + } +}); + +class MainToobar extends Component { + constructor(props) { + super(props); + this.state = { + drawer: false + }; + this.openDrawer = this.openDrawer.bind(this); + this.closeDrawer = this.closeDrawer.bind(this); + this.handleLogout = this.handleLogout.bind(this); + } + + openDrawer() { + this.setState({ + drawer: true + }); + }; + + closeDrawer() { + this.setState({ + drawer: false + }); + }; + + handleLogout() { + fetch("/api/session", { + method: "DELETE" + }).then(response => { + if (response.ok) { + this.props.history.push('/login'); + } + }); + } + + render() { + const { classes } = this.props; + return ( + <Fragment> + <AppBar position="static" className={classes.appBar}> + <Toolbar> + <IconButton + className={classes.menuButton} + color="inherit" + onClick={this.openDrawer}> + <MenuIcon /> + </IconButton> + <Typography variant="h6" color="inherit" className={classes.flex}> + Traccar + </Typography> + <Button color="inherit" onClick={this.handleLogout}>Logout</Button> + </Toolbar> + </AppBar> + <Drawer open={this.state.drawer} onClose={this.closeDrawer}> + <div + tabIndex={0} + className={classes.list} + role="button" + onClick={this.closeDrawer} + onKeyDown={this.closeDrawer}> + <List> + <ListItem button> + <ListItemIcon> + <DashboardIcon /> + </ListItemIcon> + <ListItemText primary="Dashboard" /> + </ListItem> + <ListItem button disabled> + <ListItemIcon> + <BarChartIcon /> + </ListItemIcon> + <ListItemText primary="Reports" /> + </ListItem> + </List> + <Divider /> + <List> + <ListItem button disabled> + <ListItemIcon> + <SettingsIcon /> + </ListItemIcon> + <ListItemText primary="Settings" /> + </ListItem> + </List> + </div> + </Drawer> + </Fragment> + ); + } +} + +export default withStyles(styles)(MainToobar); diff --git a/modern/src/SocketController.js b/modern/src/SocketController.js new file mode 100644 index 00000000..b89845f2 --- /dev/null +++ b/modern/src/SocketController.js @@ -0,0 +1,61 @@ +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { updateDevices, updatePositions } from './actions'; + +const displayNotifications = events => { + if ("Notification" in window) { + if (Notification.permission === "granted") { + for (const event of events) { + const notification = new Notification(`Event: ${event.type}`); + setTimeout(notification.close.bind(notification), 4 * 1000); + } + } else if (Notification.permission !== "denied") { + Notification.requestPermission(permission => { + if (permission === "granted") { + displayNotifications(events); + } + }); + } + } +}; + +class SocketController extends Component { + connectSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(protocol + '//' + window.location.host + '/api/socket'); + + socket.onclose = () => { + setTimeout(() => this.connectSocket(), 60 * 1000); + }; + + socket.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.devices) { + this.props.dispatch(updateDevices(data.devices)); + } + if (data.positions) { + this.props.dispatch(updatePositions(data.positions)); + } + if (data.events) { + displayNotifications(data.events); + } + } + } + + componentDidMount() { + fetch('/api/devices').then(response => { + if (response.ok) { + response.json().then(devices => { + this.props.dispatch(updateDevices(devices)); + }); + } + this.connectSocket(); + }); + } + + render() { + return null; + } +} + +export default connect()(SocketController); diff --git a/modern/src/actions/index.js b/modern/src/actions/index.js new file mode 100644 index 00000000..55278108 --- /dev/null +++ b/modern/src/actions/index.js @@ -0,0 +1,19 @@ +export const updateDevices = devices => ({ + type: 'UPDATE_DEVICES', + devices +}) + +export const updatePositions = positions => ({ + type: 'UPDATE_POSITIONS', + positions +}); + +export const updateEvents = events => ({ + type: 'UPDATE_EVENTS', + events +}) + +export const selectDevice = device => ({ + type: 'SELECT_DEVICE', + device +}) diff --git a/modern/src/index.js b/modern/src/index.js new file mode 100644 index 00000000..527a4d69 --- /dev/null +++ b/modern/src/index.js @@ -0,0 +1,21 @@ +import 'typeface-roboto' +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter } from 'react-router-dom' +import { Provider } from 'react-redux'; +import { createStore } from 'redux' +import rootReducer from './reducers'; +import App from './App'; +import registerServiceWorker from './registerServiceWorker'; + +const store = createStore(rootReducer); + +ReactDOM.render(( + <Provider store={store}> + <BrowserRouter> + <App /> + </BrowserRouter> + </Provider> +), document.getElementById('root')); + +registerServiceWorker(); diff --git a/modern/src/reducers/index.js b/modern/src/reducers/index.js new file mode 100644 index 00000000..752a4c3f --- /dev/null +++ b/modern/src/reducers/index.js @@ -0,0 +1,35 @@ +const initialState = { + devices: new Map(), + positions: new Map() +}; + +function updateMap(map, array, key) { + for (let value of array) { + map.set(value[key], value); + } + return map; +} + +function rootReducer(state = initialState, action) { + switch (action.type) { + case 'UPDATE_DEVICES': + return Object.assign({}, { + ...state, + devices: updateMap(state.devices, action.devices, 'id') + }); + case 'UPDATE_POSITIONS': + return Object.assign({}, { + ...state, + positions: updateMap(state.positions, action.positions, 'deviceId') + }); + case 'SELECT_DEVICE': + return Object.assign({}, { + ...state, + selectedDevice: action.device.id + }); + default: + return state; + } +} + +export default rootReducer diff --git a/modern/src/registerServiceWorker.js b/modern/src/registerServiceWorker.js new file mode 100644 index 00000000..a3e6c0cf --- /dev/null +++ b/modern/src/registerServiceWorker.js @@ -0,0 +1,117 @@ +// In production, we register a service worker to serve assets from local cache. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on the "N+1" visit to a page, since previously +// cached resources are updated in the background. + +// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. +// This link also includes instructions on opting out of this behavior. + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export default function register() { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Lets check if a service worker still exists or not. + checkValidServiceWorker(swUrl); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://goo.gl/SC7cgQ' + ); + }); + } else { + // Is not local host. Just register service worker + registerValidSW(swUrl); + } + }); + } +} + +function registerValidSW(swUrl) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the old content will have been purged and + // the fresh content will have been added to the cache. + // It's the perfect time to display a "New content is + // available; please refresh." message in your web app. + console.log('New content is available; please refresh.'); + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + if ( + response.status === 404 || + response.headers.get('content-type').indexOf('javascript') === -1 + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +} diff --git a/modern/src/setupProxy.js b/modern/src/setupProxy.js new file mode 100644 index 00000000..71312d50 --- /dev/null +++ b/modern/src/setupProxy.js @@ -0,0 +1,6 @@ +const proxy = require('http-proxy-middleware'); + +module.exports = function(app) { + app.use(proxy('/api/socket', { target: 'ws://localhost:8082', ws: true })); + app.use(proxy('/api', { target: 'http://localhost:8082' })); +}; |