aboutsummaryrefslogtreecommitdiff
path: root/src/common/components/StatusCard.jsx
diff options
context:
space:
mode:
authorAnton Tananaev <anton@traccar.org>2024-04-06 09:22:10 -0700
committerAnton Tananaev <anton@traccar.org>2024-04-06 09:22:10 -0700
commitf418231b6b2f5e030a0d2dcc390c314602b1f740 (patch)
tree10326adf3792bc2697e06bb5f2b8ef2a8f7e55fe /src/common/components/StatusCard.jsx
parentb392a4af78e01c8e0f50aad5468e9583675b24be (diff)
downloadtrackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.tar.gz
trackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.tar.bz2
trackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.zip
Move modern to the top
Diffstat (limited to 'src/common/components/StatusCard.jsx')
-rw-r--r--src/common/components/StatusCard.jsx288
1 files changed, 288 insertions, 0 deletions
diff --git a/src/common/components/StatusCard.jsx b/src/common/components/StatusCard.jsx
new file mode 100644
index 00000000..a63d0f80
--- /dev/null
+++ b/src/common/components/StatusCard.jsx
@@ -0,0 +1,288 @@
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import Draggable from 'react-draggable';
+import {
+ Card,
+ CardContent,
+ Typography,
+ CardActions,
+ IconButton,
+ Table,
+ TableBody,
+ TableRow,
+ TableCell,
+ Menu,
+ MenuItem,
+ CardMedia,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import CloseIcon from '@mui/icons-material/Close';
+import ReplayIcon from '@mui/icons-material/Replay';
+import PublishIcon from '@mui/icons-material/Publish';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import PendingIcon from '@mui/icons-material/Pending';
+
+import { useTranslation } from './LocalizationProvider';
+import RemoveDialog from './RemoveDialog';
+import PositionValue from './PositionValue';
+import { useDeviceReadonly } from '../util/permissions';
+import usePositionAttributes from '../attributes/usePositionAttributes';
+import { devicesActions } from '../../store';
+import { useCatch, useCatchCallback } from '../../reactHelper';
+import { useAttributePreference } from '../util/preferences';
+
+const useStyles = makeStyles((theme) => ({
+ card: {
+ pointerEvents: 'auto',
+ width: theme.dimensions.popupMaxWidth,
+ },
+ media: {
+ height: theme.dimensions.popupImageHeight,
+ display: 'flex',
+ justifyContent: 'flex-end',
+ alignItems: 'flex-start',
+ },
+ mediaButton: {
+ color: theme.palette.primary.contrastText,
+ mixBlendMode: 'difference',
+ },
+ header: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: theme.spacing(1, 1, 0, 2),
+ },
+ content: {
+ paddingTop: theme.spacing(1),
+ paddingBottom: theme.spacing(1),
+ maxHeight: theme.dimensions.cardContentMaxHeight,
+ overflow: 'auto',
+ },
+ delete: {
+ color: theme.palette.error.main,
+ },
+ icon: {
+ width: '25px',
+ height: '25px',
+ filter: 'brightness(0) invert(1)',
+ },
+ table: {
+ '& .MuiTableCell-sizeSmall': {
+ paddingLeft: 0,
+ paddingRight: 0,
+ },
+ },
+ cell: {
+ borderBottom: 'none',
+ },
+ actions: {
+ justifyContent: 'space-between',
+ },
+ root: ({ desktopPadding }) => ({
+ pointerEvents: 'none',
+ position: 'fixed',
+ zIndex: 5,
+ left: '50%',
+ [theme.breakpoints.up('md')]: {
+ left: `calc(50% + ${desktopPadding} / 2)`,
+ bottom: theme.spacing(3),
+ },
+ [theme.breakpoints.down('md')]: {
+ left: '50%',
+ bottom: `calc(${theme.spacing(3)} + ${theme.dimensions.bottomBarHeight}px)`,
+ },
+ transform: 'translateX(-50%)',
+ }),
+}));
+
+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, position, onClose, disableActions, desktopPadding = 0 }) => {
+ const classes = useStyles({ desktopPadding });
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const deviceReadonly = useDeviceReadonly();
+
+ const shareDisabled = useSelector((state) => state.session.server.attributes.disableShare);
+ const user = useSelector((state) => state.session.user);
+ const device = useSelector((state) => state.devices.items[deviceId]);
+
+ const deviceImage = device?.attributes?.deviceImage;
+
+ const positionAttributes = usePositionAttributes(t);
+ const positionItems = useAttributePreference('positionItems', 'speed,address,totalDistance,course');
+
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const [removing, setRemoving] = useState(false);
+
+ const handleRemove = useCatch(async (removed) => {
+ if (removed) {
+ const response = await fetch('/api/devices');
+ if (response.ok) {
+ dispatch(devicesActions.refresh(await response.json()));
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ setRemoving(false);
+ });
+
+ const handleGeofence = useCatchCallback(async () => {
+ const newItem = {
+ name: t('sharedGeofence'),
+ area: `CIRCLE (${position.latitude} ${position.longitude}, 50)`,
+ };
+ const response = await fetch('/api/geofences', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(newItem),
+ });
+ if (response.ok) {
+ const item = await response.json();
+ const permissionResponse = await fetch('/api/permissions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ deviceId: position.deviceId, geofenceId: item.id }),
+ });
+ if (!permissionResponse.ok) {
+ throw Error(await permissionResponse.text());
+ }
+ navigate(`/settings/geofence/${item.id}`);
+ } else {
+ throw Error(await response.text());
+ }
+ }, [navigate, position]);
+
+ return (
+ <>
+ <div className={classes.root}>
+ {device && (
+ <Draggable
+ handle={`.${classes.media}, .${classes.header}`}
+ >
+ <Card elevation={3} className={classes.card}>
+ {deviceImage ? (
+ <CardMedia
+ className={classes.media}
+ image={`/api/media/${device.uniqueId}/${deviceImage}`}
+ >
+ <IconButton
+ size="small"
+ onClick={onClose}
+ onTouchStart={onClose}
+ >
+ <CloseIcon fontSize="small" className={classes.mediaButton} />
+ </IconButton>
+ </CardMedia>
+ ) : (
+ <div className={classes.header}>
+ <Typography variant="body2" color="textSecondary">
+ {device.name}
+ </Typography>
+ <IconButton
+ size="small"
+ onClick={onClose}
+ onTouchStart={onClose}
+ >
+ <CloseIcon fontSize="small" />
+ </IconButton>
+ </div>
+ )}
+ {position && (
+ <CardContent className={classes.content}>
+ <Table size="small" classes={{ root: classes.table }}>
+ <TableBody>
+ {positionItems.split(',').filter((key) => position.hasOwnProperty(key) || position.attributes.hasOwnProperty(key)).map((key) => (
+ <StatusRow
+ key={key}
+ name={positionAttributes[key]?.name || key}
+ content={(
+ <PositionValue
+ position={position}
+ property={position.hasOwnProperty(key) ? key : null}
+ attribute={position.hasOwnProperty(key) ? null : key}
+ />
+ )}
+ />
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ )}
+ <CardActions classes={{ root: classes.actions }} disableSpacing>
+ <IconButton
+ color="secondary"
+ onClick={(e) => setAnchorEl(e.currentTarget)}
+ disabled={!position}
+ >
+ <PendingIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => navigate('/replay')}
+ disabled={disableActions || !position}
+ >
+ <ReplayIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => navigate(`/settings/device/${deviceId}/command`)}
+ disabled={disableActions}
+ >
+ <PublishIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => navigate(`/settings/device/${deviceId}`)}
+ disabled={disableActions || deviceReadonly}
+ >
+ <EditIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => setRemoving(true)}
+ disabled={disableActions || deviceReadonly}
+ className={classes.delete}
+ >
+ <DeleteIcon />
+ </IconButton>
+ </CardActions>
+ </Card>
+ </Draggable>
+ )}
+ </div>
+ {position && (
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
+ <MenuItem onClick={() => navigate(`/position/${position.id}`)}><Typography color="secondary">{t('sharedShowDetails')}</Typography></MenuItem>
+ <MenuItem onClick={handleGeofence}>{t('sharedCreateGeofence')}</MenuItem>
+ <MenuItem component="a" target="_blank" href={`https://www.google.com/maps/search/?api=1&query=${position.latitude}%2C${position.longitude}`}>{t('linkGoogleMaps')}</MenuItem>
+ <MenuItem component="a" target="_blank" href={`http://maps.apple.com/?ll=${position.latitude},${position.longitude}`}>{t('linkAppleMaps')}</MenuItem>
+ <MenuItem component="a" target="_blank" href={`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${position.latitude}%2C${position.longitude}&heading=${position.course}`}>{t('linkStreetView')}</MenuItem>
+ {!shareDisabled && !user.temporary && <MenuItem onClick={() => navigate(`/settings/device/${deviceId}/share`)}>{t('deviceShare')}</MenuItem>}
+ </Menu>
+ )}
+ <RemoveDialog
+ open={removing}
+ endpoint="devices"
+ itemId={deviceId}
+ onResult={(removed) => handleRemove(removed)}
+ />
+ </>
+ );
+};
+
+export default StatusCard;