aboutsummaryrefslogtreecommitdiff
path: root/modern/src/main
diff options
context:
space:
mode:
authorAnton Tananaev <anton@traccar.org>2022-05-08 13:16:57 -0700
committerAnton Tananaev <anton@traccar.org>2022-05-08 13:16:57 -0700
commit2cd374bb9fa941d7e2a6fd8aa5079893a158c98f (patch)
treef4ee48130592fed5de25dce7af4ac0cbeb017680 /modern/src/main
parent2352071211b61c10fa5bf5736baaff7809d18bf0 (diff)
downloadtrackermap-web-2cd374bb9fa941d7e2a6fd8aa5079893a158c98f.tar.gz
trackermap-web-2cd374bb9fa941d7e2a6fd8aa5079893a158c98f.tar.bz2
trackermap-web-2cd374bb9fa941d7e2a6fd8aa5079893a158c98f.zip
Reorganize remaining files
Diffstat (limited to 'modern/src/main')
-rw-r--r--modern/src/main/DevicesList.js195
-rw-r--r--modern/src/main/MainPage.js213
-rw-r--r--modern/src/main/PositionPage.js99
-rw-r--r--modern/src/main/StatusCard.js134
4 files changed, 641 insertions, 0 deletions
diff --git a/modern/src/main/DevicesList.js b/modern/src/main/DevicesList.js
new file mode 100644
index 00000000..57778667
--- /dev/null
+++ b/modern/src/main/DevicesList.js
@@ -0,0 +1,195 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { makeStyles } from '@material-ui/core/styles';
+import { IconButton, Tooltip } from '@material-ui/core';
+import Avatar from '@material-ui/core/Avatar';
+import List from '@material-ui/core/List';
+import ListItem from '@material-ui/core/ListItem';
+import ListItemAvatar from '@material-ui/core/ListItemAvatar';
+import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
+import ListItemText from '@material-ui/core/ListItemText';
+import { FixedSizeList } from 'react-window';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import BatteryFullIcon from '@material-ui/icons/BatteryFull';
+import BatteryChargingFullIcon from '@material-ui/icons/BatteryChargingFull';
+import Battery60Icon from '@material-ui/icons/Battery60';
+import BatteryCharging60Icon from '@material-ui/icons/BatteryCharging60';
+import Battery20Icon from '@material-ui/icons/Battery20';
+import BatteryCharging20Icon from '@material-ui/icons/BatteryCharging20';
+import FlashOnIcon from '@material-ui/icons/FlashOn';
+import FlashOffIcon from '@material-ui/icons/FlashOff';
+import ErrorIcon from '@material-ui/icons/Error';
+
+import { devicesActions } from '../store';
+import EditCollectionView from '../settings/components/EditCollectionView';
+import { useEffectAsync } from '../reactHelper';
+import {
+ formatAlarm, formatBoolean, formatPercentage, formatStatus, getStatusColor,
+} from '../common/util/formatter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+
+const useStyles = makeStyles((theme) => ({
+ list: {
+ maxHeight: '100%',
+ },
+ listInner: {
+ position: 'relative',
+ margin: theme.spacing(1.5, 0),
+ },
+ icon: {
+ width: '25px',
+ height: '25px',
+ filter: 'brightness(0) invert(1)',
+ },
+ listItem: {
+ backgroundColor: 'white',
+ '&:hover': {
+ backgroundColor: 'white',
+ },
+ },
+ batteryText: {
+ fontSize: '0.75rem',
+ fontWeight: 'normal',
+ lineHeight: '0.875rem',
+ },
+ positive: {
+ color: theme.palette.colors.positive,
+ },
+ medium: {
+ color: theme.palette.colors.medium,
+ },
+ negative: {
+ color: theme.palette.colors.negative,
+ },
+ neutral: {
+ color: theme.palette.colors.neutral,
+ },
+ indicators: {
+ lineHeight: 1,
+ },
+}));
+
+const DeviceRow = ({ data, index, style }) => {
+ const classes = useStyles();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const { items } = data;
+ const item = items[index];
+ const position = useSelector((state) => state.positions.items[item.id]);
+
+ return (
+ <div style={style}>
+ <ListItem button key={item.id} className={classes.listItem} onClick={() => dispatch(devicesActions.select(item.id))}>
+ <ListItemAvatar>
+ <Avatar>
+ <img className={classes.icon} src={`images/icon/${item.category || 'default'}.svg`} alt="" />
+ </Avatar>
+ </ListItemAvatar>
+ <ListItemText
+ primary={item.name}
+ secondary={formatStatus(item.status, t)}
+ classes={{ secondary: classes[getStatusColor(item.status)] }}
+ />
+ <ListItemSecondaryAction className={classes.indicators}>
+ {position && (
+ <>
+ {position.attributes.hasOwnProperty('alarm') && (
+ <Tooltip title={`${t('eventAlarm')}: ${formatAlarm(position.attributes.alarm, t)}`}>
+ <IconButton size="small">
+ <ErrorIcon fontSize="small" className={classes.negative} />
+ </IconButton>
+ </Tooltip>
+ )}
+ {position.attributes.hasOwnProperty('ignition') && (
+ <Tooltip title={`${t('positionIgnition')}: ${formatBoolean(position.attributes.ignition, t)}`}>
+ <IconButton size="small">
+ {position.attributes.ignition ? (
+ <FlashOnIcon fontSize="small" className={classes.positive} />
+ ) : (
+ <FlashOffIcon fontSize="small" className={classes.neutral} />
+ )}
+ </IconButton>
+ </Tooltip>
+ )}
+ {position.attributes.hasOwnProperty('batteryLevel') && (
+ <Tooltip title={`${t('positionBatteryLevel')}: ${formatPercentage(position.attributes.batteryLevel)}`}>
+ <IconButton size="small">
+ {position.attributes.batteryLevel > 70 ? (
+ position.attributes.charge
+ ? (<BatteryChargingFullIcon fontSize="small" className={classes.positive} />)
+ : (<BatteryFullIcon fontSize="small" className={classes.positive} />)
+ ) : position.attributes.batteryLevel > 30 ? (
+ position.attributes.charge
+ ? (<BatteryCharging60Icon fontSize="small" className={classes.medium} />)
+ : (<Battery60Icon fontSize="small" className={classes.medium} />)
+ ) : (
+ position.attributes.charge
+ ? (<BatteryCharging20Icon fontSize="small" className={classes.negative} />)
+ : (<Battery20Icon fontSize="small" className={classes.negative} />)
+ )}
+ </IconButton>
+ </Tooltip>
+ )}
+ </>
+ )}
+ </ListItemSecondaryAction>
+ </ListItem>
+ </div>
+ );
+};
+
+const DeviceView = ({ updateTimestamp, onMenuClick, filter }) => {
+ const classes = useStyles();
+ const dispatch = useDispatch();
+ const listInnerEl = useRef(null);
+
+ const items = useSelector((state) => state.devices.items);
+ const [filteredItems, setFilteredItems] = useState(null);
+
+ useEffect(() => {
+ const array = Object.values(items);
+ setFilteredItems(
+ filter.trim().length > 0
+ ? array.filter((item) => `${item.name} ${item.uniqueId}`.toLowerCase().includes(filter?.toLowerCase()))
+ : array,
+ );
+ }, [filter, items]);
+
+ if (listInnerEl.current) {
+ listInnerEl.current.className = classes.listInner;
+ }
+
+ useEffectAsync(async () => {
+ const response = await fetch('/api/devices');
+ if (response.ok) {
+ dispatch(devicesActions.refresh(await response.json()));
+ }
+ }, [updateTimestamp]);
+
+ return (
+ <AutoSizer className={classes.list}>
+ {({ height, width }) => (
+ <List disablePadding>
+ <FixedSizeList
+ width={width}
+ height={height}
+ itemCount={filteredItems.length}
+ itemData={{ items: filteredItems, onMenuClick }}
+ itemSize={72}
+ overscanCount={10}
+ innerRef={listInnerEl}
+ >
+ {DeviceRow}
+ </FixedSizeList>
+ </List>
+ )}
+ </AutoSizer>
+ );
+};
+
+const DevicesList = ({ filter }) => (
+ <EditCollectionView content={DeviceView} editPath="/device" endpoint="devices" disableAdd filter={filter} />
+);
+
+export default DevicesList;
diff --git a/modern/src/main/MainPage.js b/modern/src/main/MainPage.js
new file mode 100644
index 00000000..569978af
--- /dev/null
+++ b/modern/src/main/MainPage.js
@@ -0,0 +1,213 @@
+import React, { useState, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import {
+ makeStyles, Paper, Toolbar, TextField, IconButton, Button,
+} from '@material-ui/core';
+
+import { useTheme } from '@material-ui/core/styles';
+import useMediaQuery from '@material-ui/core/useMediaQuery';
+import AddIcon from '@material-ui/icons/Add';
+import CloseIcon from '@material-ui/icons/Close';
+import ArrowBackIcon from '@material-ui/icons/ArrowBack';
+import ListIcon from '@material-ui/icons/ViewList';
+
+import { useDispatch, useSelector } from 'react-redux';
+import DevicesList from './DevicesList';
+import Map from '../map/core/Map';
+import SelectedDeviceMap from '../map/main/SelectedDeviceMap';
+import AccuracyMap from '../map/main/AccuracyMap';
+import GeofenceMap from '../map/main/GeofenceMap';
+import CurrentPositionsMap from '../map/main/CurrentPositionsMap';
+import CurrentLocationMap from '../map/CurrentLocationMap';
+import BottomMenu from '../common/components/BottomMenu';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PoiMap from '../map/main/PoiMap';
+import MapPadding from '../map/MapPadding';
+import StatusCard from './StatusCard';
+import { devicesActions } from '../store';
+import DefaultCameraMap from '../map/main/DefaultCameraMap';
+import usePersistedState from '../common/util/usePersistedState';
+import LiveRoutesMap from '../map/main/LiveRoutesMap';
+import { useDeviceReadonly } from '../common/util/permissions';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ height: '100%',
+ },
+ sidebar: {
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ zIndex: 3,
+ margin: theme.spacing(1.5),
+ width: theme.dimensions.drawerWidthDesktop,
+ bottom: theme.dimensions.bottomBarHeight,
+ transition: 'transform .5s ease',
+ backgroundColor: 'white',
+ [theme.breakpoints.down('sm')]: {
+ width: '100%',
+ margin: 0,
+ },
+ },
+ sidebarCollapsed: {
+ transform: `translateX(-${theme.dimensions.drawerWidthDesktop})`,
+ marginLeft: 0,
+ [theme.breakpoints.down('sm')]: {
+ transform: 'translateX(-100vw)',
+ },
+ },
+ toolbar: {
+ display: 'flex',
+ padding: theme.spacing(0, 1),
+ '& > *': {
+ margin: theme.spacing(0, 1),
+ },
+ },
+ deviceList: {
+ flex: 1,
+ },
+ statusCard: {
+ position: 'fixed',
+ zIndex: 5,
+ [theme.breakpoints.up('sm')]: {
+ left: `calc(50% + ${theme.dimensions.drawerWidthDesktop} / 2)`,
+ bottom: theme.spacing(3),
+ },
+ [theme.breakpoints.down('sm')]: {
+ left: '50%',
+ bottom: theme.spacing(3) + theme.dimensions.bottomBarHeight,
+ },
+ transform: 'translateX(-50%)',
+ },
+ sidebarToggle: {
+ position: 'absolute',
+ left: theme.spacing(1.5),
+ top: theme.spacing(3),
+ borderRadius: '0px',
+ minWidth: 0,
+ [theme.breakpoints.down('sm')]: {
+ left: 0,
+ },
+ },
+ sidebarToggleText: {
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.only('xs')]: {
+ display: 'none',
+ },
+ },
+ sidebarToggleBg: {
+ backgroundColor: 'white',
+ color: '#777777',
+ '&:hover': {
+ backgroundColor: 'white',
+ },
+ },
+ bottomMenu: {
+ position: 'fixed',
+ left: theme.spacing(1.5),
+ bottom: theme.spacing(1.5),
+ zIndex: 4,
+ width: theme.dimensions.drawerWidthDesktop,
+ },
+}));
+
+const MainPage = () => {
+ const classes = useStyles();
+ const history = useHistory();
+ const dispatch = useDispatch();
+ const theme = useTheme();
+ const t = useTranslation();
+
+ const deviceReadonly = useDeviceReadonly();
+ const isTablet = useMediaQuery(theme.breakpoints.down('sm'));
+ const isPhone = useMediaQuery(theme.breakpoints.down('xs'));
+
+ const [mapLiveRoutes] = usePersistedState('mapLiveRoutes', false);
+
+ const selectedDeviceId = useSelector((state) => state.devices.selectedId);
+
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [collapsed, setCollapsed] = useState(false);
+
+ const handleClose = () => {
+ setCollapsed(!collapsed);
+ };
+
+ useEffect(() => setCollapsed(isTablet), [isTablet]);
+
+ return (
+ <div className={classes.root}>
+ <Map>
+ {!isTablet && <MapPadding left={parseInt(theme.dimensions.drawerWidthDesktop, 10)} />}
+ <CurrentLocationMap />
+ <GeofenceMap />
+ <AccuracyMap />
+ {mapLiveRoutes && <LiveRoutesMap />}
+ <CurrentPositionsMap />
+ <DefaultCameraMap />
+ <SelectedDeviceMap />
+ <PoiMap />
+ </Map>
+ <Button
+ variant="contained"
+ color={isPhone ? 'secondary' : 'primary'}
+ classes={{ containedPrimary: classes.sidebarToggleBg }}
+ className={classes.sidebarToggle}
+ onClick={handleClose}
+ disableElevation
+ >
+ <ListIcon />
+ <div className={classes.sidebarToggleText}>{t('deviceTitle')}</div>
+ </Button>
+ <Paper square elevation={3} className={`${classes.sidebar} ${collapsed && classes.sidebarCollapsed}`}>
+ <Paper square elevation={3}>
+ <Toolbar className={classes.toolbar} disableGutters>
+ {isTablet && (
+ <IconButton onClick={handleClose}>
+ <ArrowBackIcon />
+ </IconButton>
+ )}
+ <TextField
+ fullWidth
+ name="searchKeyword"
+ value={searchKeyword}
+ autoComplete="searchKeyword"
+ autoFocus
+ onChange={(event) => setSearchKeyword(event.target.value)}
+ placeholder={t('sharedSearchDevices')}
+ variant="filled"
+ />
+ <IconButton onClick={() => history.push('/device')} disabled={deviceReadonly}>
+ <AddIcon />
+ </IconButton>
+ {!isTablet && (
+ <IconButton onClick={handleClose}>
+ <CloseIcon />
+ </IconButton>
+ )}
+ </Toolbar>
+ </Paper>
+ <div className={classes.deviceList}>
+ <DevicesList filter={searchKeyword} />
+ </div>
+ </Paper>
+ {!isPhone && !isTablet && (
+ <div className={classes.bottomMenu}>
+ <BottomMenu />
+ </div>
+ )}
+ {selectedDeviceId && (
+ <div className={classes.statusCard}>
+ <StatusCard
+ deviceId={selectedDeviceId}
+ onClose={() => dispatch(devicesActions.select(null))}
+ />
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default MainPage;
diff --git a/modern/src/main/PositionPage.js b/modern/src/main/PositionPage.js
new file mode 100644
index 00000000..ecb4095d
--- /dev/null
+++ b/modern/src/main/PositionPage.js
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+
+import {
+ makeStyles, Typography, Container, Paper, AppBar, Toolbar, IconButton, Table, TableHead, TableRow, TableCell, TableBody,
+} from '@material-ui/core';
+import ArrowBackIcon from '@material-ui/icons/ArrowBack';
+import { useHistory, useParams } from 'react-router-dom';
+import { useEffectAsync } from '../reactHelper';
+import { prefixString } from '../common/util/stringUtils';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PositionValue from '../common/components/PositionValue';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ paddingTop: theme.spacing(1),
+ paddingBottom: theme.spacing(1),
+ },
+}));
+
+const PositionPage = () => {
+ const classes = useStyles();
+ const history = useHistory();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const [item, setItem] = useState();
+
+ useEffectAsync(async () => {
+ if (id) {
+ const response = await fetch(`/api/positions?id=${id}`);
+ if (response.ok) {
+ const positions = await response.json();
+ if (positions.length > 0) {
+ setItem(positions[0]);
+ }
+ }
+ } else {
+ setItem({});
+ }
+ }, [id]);
+
+ const deviceName = useSelector((state) => {
+ if (item) {
+ const device = state.devices.items[item.deviceId];
+ if (device) {
+ return device.name;
+ }
+ }
+ return null;
+ });
+
+ return (
+ <>
+ <AppBar position="sticky" color="inherit">
+ <Toolbar>
+ <IconButton color="inherit" edge="start" onClick={() => history.push('/')}>
+ <ArrowBackIcon />
+ </IconButton>
+ <Typography variant="h6">
+ {deviceName}
+ </Typography>
+ </Toolbar>
+ </AppBar>
+ <Container maxWidth="sm" className={classes.root}>
+ <Paper>
+ <Table>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('stateName')}</TableCell>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell>{t('stateValue')}</TableCell>
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {item && Object.getOwnPropertyNames(item).filter((it) => it !== 'attributes').map((property) => (
+ <TableRow key={property}>
+ <TableCell>{property}</TableCell>
+ <TableCell><strong>{t(prefixString('position', property))}</strong></TableCell>
+ <TableCell><PositionValue position={item} property={property} /></TableCell>
+ </TableRow>
+ ))}
+ {item && Object.getOwnPropertyNames(item.attributes).map((attribute) => (
+ <TableRow key={attribute}>
+ <TableCell>{attribute}</TableCell>
+ <TableCell><strong>{t(prefixString('position', attribute)) || t(prefixString('device', attribute))}</strong></TableCell>
+ <TableCell><PositionValue position={item} attribute={attribute} /></TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Paper>
+ </Container>
+ </>
+ );
+};
+
+export default PositionPage;
diff --git a/modern/src/main/StatusCard.js b/modern/src/main/StatusCard.js
new file mode 100644
index 00000000..46d288c6
--- /dev/null
+++ b/modern/src/main/StatusCard.js
@@ -0,0 +1,134 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useHistory } from 'react-router-dom';
+import {
+ makeStyles, Button, Card, CardContent, Typography, CardActions, CardHeader, IconButton, Avatar, Table, TableBody, TableRow, TableCell, TableContainer,
+} from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import ReplayIcon from '@material-ui/icons/Replay';
+import PublishIcon from '@material-ui/icons/Publish';
+import EditIcon from '@material-ui/icons/Edit';
+import DeleteIcon from '@material-ui/icons/Delete';
+
+import { useTranslation } from '../common/components/LocalizationProvider';
+import { formatStatus } from '../common/util/formatter';
+import RemoveDialog from '../common/components/RemoveDialog';
+import PositionValue from '../common/components/PositionValue';
+import dimensions from '../common/theme/dimensions';
+import { useDeviceReadonly, useReadonly } from '../common/util/permissions';
+
+const useStyles = makeStyles((theme) => ({
+ card: {
+ width: dimensions.popupMaxWidth,
+ },
+ negative: {
+ color: theme.palette.colors.negative,
+ },
+ icon: {
+ width: '25px',
+ height: '25px',
+ filter: 'brightness(0) invert(1)',
+ },
+ table: {
+ '& .MuiTableCell-sizeSmall': {
+ paddingLeft: theme.spacing(0.5),
+ paddingRight: theme.spacing(0.5),
+ },
+ },
+ cell: {
+ borderBottom: 'none',
+ },
+}));
+
+const StatusRow = ({ name, content }) => {
+ const classes = useStyles();
+
+ return (
+ <TableRow>
+ <TableCell className={classes.cell}>
+ <Typography variant="body2">{name}</Typography>
+ </TableCell>
+ <TableCell className={classes.cell}>
+ <Typography variant="body2" color="textSecondary">{content}</Typography>
+ </TableCell>
+ </TableRow>
+ );
+};
+
+const StatusCard = ({ deviceId, onClose }) => {
+ const classes = useStyles();
+ const history = useHistory();
+ const t = useTranslation();
+
+ const readonly = useReadonly();
+ const deviceReadonly = useDeviceReadonly();
+
+ const device = useSelector((state) => state.devices.items[deviceId]);
+ const position = useSelector((state) => state.positions.items[deviceId]);
+
+ const [removeDialogShown, setRemoveDialogShown] = useState(false);
+
+ return (
+ <>
+ {device && (
+ <Card elevation={3} className={classes.card}>
+ <CardHeader
+ avatar={(
+ <Avatar>
+ <img className={classes.icon} src={`images/icon/${device.category || 'default'}.svg`} alt="" />
+ </Avatar>
+ )}
+ action={(
+ <IconButton onClick={onClose}>
+ <CloseIcon />
+ </IconButton>
+ )}
+ title={device.name}
+ subheader={formatStatus(device.status, t)}
+ />
+ {position && (
+ <CardContent>
+ <TableContainer>
+ <Table size="small" classes={{ root: classes.table }}>
+ <TableBody>
+ <StatusRow name={t('positionSpeed')} content={<PositionValue position={position} property="speed" />} />
+ <StatusRow name={t('positionAddress')} content={<PositionValue position={position} property="address" />} />
+ {position.attributes.odometer
+ ? <StatusRow name={t('positionOdometer')} content={<PositionValue position={position} attribute="odometer" />} />
+ : <StatusRow name={t('deviceTotalDistance')} content={<PositionValue position={position} attribute="totalDistance" />} />}
+ <StatusRow name={t('positionCourse')} content={<PositionValue position={position} property="course" />} />
+ </TableBody>
+ </Table>
+ </TableContainer>
+ </CardContent>
+ )}
+ <CardActions disableSpacing>
+ <Button onClick={() => history.push(`/position/${position.id}`)} disabled={!position} color="secondary">
+ {t('sharedInfoTitle')}
+ </Button>
+ <IconButton onClick={() => history.push('/replay')} disabled={!position}>
+ <ReplayIcon />
+ </IconButton>
+ <IconButton onClick={() => history.push(`/command/${deviceId}`)} disabled={readonly}>
+ <PublishIcon />
+ </IconButton>
+ <IconButton onClick={() => history.push(`/device/${deviceId}`)} disabled={deviceReadonly}>
+ <EditIcon />
+ </IconButton>
+ <IconButton onClick={() => setRemoveDialogShown(true)} disabled={deviceReadonly} className={classes.negative}>
+ <DeleteIcon />
+ </IconButton>
+ </CardActions>
+ </Card>
+ )}
+ <RemoveDialog
+ open={removeDialogShown}
+ endpoint="devices"
+ itemId={deviceId}
+ onResult={() => setRemoveDialogShown(false)}
+ />
+ </>
+ );
+};
+
+export default StatusCard;