diff options
author | Anton Tananaev <anton@traccar.org> | 2022-05-08 13:16:57 -0700 |
---|---|---|
committer | Anton Tananaev <anton@traccar.org> | 2022-05-08 13:16:57 -0700 |
commit | 2cd374bb9fa941d7e2a6fd8aa5079893a158c98f (patch) | |
tree | f4ee48130592fed5de25dce7af4ac0cbeb017680 /modern/src/main | |
parent | 2352071211b61c10fa5bf5736baaff7809d18bf0 (diff) | |
download | trackermap-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.js | 195 | ||||
-rw-r--r-- | modern/src/main/MainPage.js | 213 | ||||
-rw-r--r-- | modern/src/main/PositionPage.js | 99 | ||||
-rw-r--r-- | modern/src/main/StatusCard.js | 134 |
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; |