diff options
Diffstat (limited to 'modern/src')
-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 |
12 files changed, 866 insertions, 0 deletions
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' })); +}; |