aboutsummaryrefslogtreecommitdiff
path: root/modern
diff options
context:
space:
mode:
authorAnton Tananaev <anton.tananaev@gmail.com>2020-03-22 23:07:16 -0700
committerGitHub <noreply@github.com>2020-03-22 23:07:16 -0700
commit48a726021f5d3c741749094891d529ccb3ba59b4 (patch)
tree8df80eca54f9dd39664f63365ffcc2ec248fb3df /modern
parentf5165c8e897e8d9cf4219d943e2d34b61adb48b5 (diff)
parentba9cc86f667486a09edb323402c2d63ada5ea639 (diff)
downloadetbsa-traccar-web-48a726021f5d3c741749094891d529ccb3ba59b4.tar.gz
etbsa-traccar-web-48a726021f5d3c741749094891d529ccb3ba59b4.tar.bz2
etbsa-traccar-web-48a726021f5d3c741749094891d529ccb3ba59b4.zip
Merge pull request #768 from traccar/modern
Create a new React web app
Diffstat (limited to 'modern')
-rw-r--r--modern/.env1
-rw-r--r--modern/.gitignore21
-rw-r--r--modern/.vscode/launch.json15
-rw-r--r--modern/README.md11
-rw-r--r--modern/package.json38
-rw-r--r--modern/public/favicon.icobin0 -> 15086 bytes
-rw-r--r--modern/public/images/background.svg10
-rw-r--r--modern/public/images/icon/marker.svg2
-rw-r--r--modern/public/index.html17
-rw-r--r--modern/public/logo.svg33
-rw-r--r--modern/public/manifest.json15
-rw-r--r--modern/src/App.js21
-rw-r--r--modern/src/DeviceList.js52
-rw-r--r--modern/src/LoginPage.js148
-rw-r--r--modern/src/MainMap.js172
-rw-r--r--modern/src/MainPage.js91
-rw-r--r--modern/src/MainToolbar.js123
-rw-r--r--modern/src/SocketController.js61
-rw-r--r--modern/src/actions/index.js19
-rw-r--r--modern/src/index.js21
-rw-r--r--modern/src/reducers/index.js35
-rw-r--r--modern/src/registerServiceWorker.js117
-rw-r--r--modern/src/setupProxy.js6
23 files changed, 1029 insertions, 0 deletions
diff --git a/modern/.env b/modern/.env
new file mode 100644
index 0000000..6f809cc
--- /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 0000000..d30f40e
--- /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 0000000..8bd51ee
--- /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 0000000..6b8edbb
--- /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 0000000..04c1b4a
--- /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
new file mode 100644
index 0000000..6be99dd
--- /dev/null
+++ b/modern/public/favicon.ico
Binary files differ
diff --git a/modern/public/images/background.svg b/modern/public/images/background.svg
new file mode 100644
index 0000000..3dcb687
--- /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 0000000..f626547
--- /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 0000000..29800eb
--- /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 0000000..008b46d
--- /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 0000000..350ef8a
--- /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 0000000..31b8b69
--- /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 0000000..03c5126
--- /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 0000000..c094fa3
--- /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 0000000..35b933b
--- /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 0000000..450a5e0
--- /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 0000000..6e2e4d6
--- /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 0000000..b89845f
--- /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 0000000..5527810
--- /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 0000000..527a4d6
--- /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 0000000..752a4c3
--- /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 0000000..a3e6c0c
--- /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 0000000..71312d5
--- /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' }));
+};