aboutsummaryrefslogtreecommitdiff
path: root/src/common/components
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
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')
-rw-r--r--src/common/components/AddressValue.jsx37
-rw-r--r--src/common/components/BottomMenu.jsx135
-rw-r--r--src/common/components/DriverValue.js9
-rw-r--r--src/common/components/ErrorHandler.jsx27
-rw-r--r--src/common/components/GeofencesValue.js9
-rw-r--r--src/common/components/LinkField.jsx93
-rw-r--r--src/common/components/LocalizationProvider.jsx187
-rw-r--r--src/common/components/NativeInterface.js72
-rw-r--r--src/common/components/NavBar.jsx25
-rw-r--r--src/common/components/PageLayout.jsx118
-rw-r--r--src/common/components/PositionValue.jsx133
-rw-r--r--src/common/components/RemoveDialog.jsx54
-rw-r--r--src/common/components/SelectField.jsx77
-rw-r--r--src/common/components/SideNav.jsx33
-rw-r--r--src/common/components/SplitButton.jsx48
-rw-r--r--src/common/components/StatusCard.jsx288
-rw-r--r--src/common/components/TableShimmer.jsx17
17 files changed, 1362 insertions, 0 deletions
diff --git a/src/common/components/AddressValue.jsx b/src/common/components/AddressValue.jsx
new file mode 100644
index 00000000..827a71de
--- /dev/null
+++ b/src/common/components/AddressValue.jsx
@@ -0,0 +1,37 @@
+import React, { useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { Link } from '@mui/material';
+import { useTranslation } from './LocalizationProvider';
+import { useCatch } from '../../reactHelper';
+
+const AddressValue = ({ latitude, longitude, originalAddress }) => {
+ const t = useTranslation();
+
+ const addressEnabled = useSelector((state) => state.session.server.geocoderEnabled);
+
+ const [address, setAddress] = useState();
+
+ useEffect(() => {
+ setAddress(originalAddress);
+ }, [latitude, longitude, originalAddress]);
+
+ const showAddress = useCatch(async () => {
+ const query = new URLSearchParams({ latitude, longitude });
+ const response = await fetch(`/api/server/geocode?${query.toString()}`);
+ if (response.ok) {
+ setAddress(await response.text());
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ if (address) {
+ return address;
+ }
+ if (addressEnabled) {
+ return (<Link href="#" onClick={showAddress}>{t('sharedShowAddress')}</Link>);
+ }
+ return '';
+};
+
+export default AddressValue;
diff --git a/src/common/components/BottomMenu.jsx b/src/common/components/BottomMenu.jsx
new file mode 100644
index 00000000..07fa2e11
--- /dev/null
+++ b/src/common/components/BottomMenu.jsx
@@ -0,0 +1,135 @@
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate, useLocation } from 'react-router-dom';
+import {
+ Paper, BottomNavigation, BottomNavigationAction, Menu, MenuItem, Typography, Badge,
+} from '@mui/material';
+
+import DescriptionIcon from '@mui/icons-material/Description';
+import SettingsIcon from '@mui/icons-material/Settings';
+import MapIcon from '@mui/icons-material/Map';
+import PersonIcon from '@mui/icons-material/Person';
+import ExitToAppIcon from '@mui/icons-material/ExitToApp';
+
+import { sessionActions } from '../../store';
+import { useTranslation } from './LocalizationProvider';
+import { useRestriction } from '../util/permissions';
+import { nativePostMessage } from './NativeInterface';
+
+const BottomMenu = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const readonly = useRestriction('readonly');
+ const disableReports = useRestriction('disableReports');
+ const user = useSelector((state) => state.session.user);
+ const socket = useSelector((state) => state.session.socket);
+
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const currentSelection = () => {
+ if (location.pathname === `/settings/user/${user.id}`) {
+ return 'account';
+ } if (location.pathname.startsWith('/settings')) {
+ return 'settings';
+ } if (location.pathname.startsWith('/reports')) {
+ return 'reports';
+ } if (location.pathname === '/') {
+ return 'map';
+ }
+ return null;
+ };
+
+ const handleAccount = () => {
+ setAnchorEl(null);
+ navigate(`/settings/user/${user.id}`);
+ };
+
+ const handleLogout = async () => {
+ setAnchorEl(null);
+
+ const notificationToken = window.localStorage.getItem('notificationToken');
+ if (notificationToken && !user.readonly) {
+ window.localStorage.removeItem('notificationToken');
+ const tokens = user.attributes.notificationTokens?.split(',') || [];
+ if (tokens.includes(notificationToken)) {
+ const updatedUser = {
+ ...user,
+ attributes: {
+ ...user.attributes,
+ notificationTokens: tokens.length > 1 ? tokens.filter((it) => it !== notificationToken).join(',') : undefined,
+ },
+ };
+ await fetch(`/api/users/${user.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updatedUser),
+ });
+ }
+ }
+
+ await fetch('/api/session', { method: 'DELETE' });
+ nativePostMessage('logout');
+ navigate('/login');
+ dispatch(sessionActions.updateUser(null));
+ };
+
+ const handleSelection = (event, value) => {
+ switch (value) {
+ case 'map':
+ navigate('/');
+ break;
+ case 'reports':
+ navigate('/reports/combined');
+ break;
+ case 'settings':
+ navigate('/settings/preferences');
+ break;
+ case 'account':
+ setAnchorEl(event.currentTarget);
+ break;
+ case 'logout':
+ handleLogout();
+ break;
+ default:
+ break;
+ }
+ };
+
+ return (
+ <Paper square elevation={3}>
+ <BottomNavigation value={currentSelection()} onChange={handleSelection} showLabels>
+ <BottomNavigationAction
+ label={t('mapTitle')}
+ icon={(
+ <Badge color="error" variant="dot" overlap="circular" invisible={socket !== false}>
+ <MapIcon />
+ </Badge>
+ )}
+ value="map"
+ />
+ {!disableReports && (
+ <BottomNavigationAction label={t('reportTitle')} icon={<DescriptionIcon />} value="reports" />
+ )}
+ <BottomNavigationAction label={t('settingsTitle')} icon={<SettingsIcon />} value="settings" />
+ {readonly ? (
+ <BottomNavigationAction label={t('loginLogout')} icon={<ExitToAppIcon />} value="logout" />
+ ) : (
+ <BottomNavigationAction label={t('settingsUser')} icon={<PersonIcon />} value="account" />
+ )}
+ </BottomNavigation>
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
+ <MenuItem onClick={handleAccount}>
+ <Typography color="textPrimary">{t('settingsUser')}</Typography>
+ </MenuItem>
+ <MenuItem onClick={handleLogout}>
+ <Typography color="error">{t('loginLogout')}</Typography>
+ </MenuItem>
+ </Menu>
+ </Paper>
+ );
+};
+
+export default BottomMenu;
diff --git a/src/common/components/DriverValue.js b/src/common/components/DriverValue.js
new file mode 100644
index 00000000..6148b418
--- /dev/null
+++ b/src/common/components/DriverValue.js
@@ -0,0 +1,9 @@
+import { useSelector } from 'react-redux';
+
+const DriverValue = ({ driverUniqueId }) => {
+ const driver = useSelector((state) => state.drivers.items[driverUniqueId]);
+
+ return driver?.name || driverUniqueId;
+};
+
+export default DriverValue;
diff --git a/src/common/components/ErrorHandler.jsx b/src/common/components/ErrorHandler.jsx
new file mode 100644
index 00000000..5c9c26d9
--- /dev/null
+++ b/src/common/components/ErrorHandler.jsx
@@ -0,0 +1,27 @@
+import { Snackbar, Alert } from '@mui/material';
+import React from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { usePrevious } from '../../reactHelper';
+import { errorsActions } from '../../store';
+
+const ErrorHandler = () => {
+ const dispatch = useDispatch();
+
+ const error = useSelector((state) => state.errors.errors.find(() => true));
+ const previousError = usePrevious(error);
+
+ return (
+ <Snackbar open={!!error}>
+ <Alert
+ elevation={6}
+ onClose={() => dispatch(errorsActions.pop())}
+ severity="error"
+ variant="filled"
+ >
+ {error || previousError}
+ </Alert>
+ </Snackbar>
+ );
+};
+
+export default ErrorHandler;
diff --git a/src/common/components/GeofencesValue.js b/src/common/components/GeofencesValue.js
new file mode 100644
index 00000000..4808a8a2
--- /dev/null
+++ b/src/common/components/GeofencesValue.js
@@ -0,0 +1,9 @@
+import { useSelector } from 'react-redux';
+
+const GeofencesValue = ({ geofenceIds }) => {
+ const geofences = useSelector((state) => state.geofences.items);
+
+ return geofenceIds.map((id) => geofences[id]?.name).join(', ');
+};
+
+export default GeofencesValue;
diff --git a/src/common/components/LinkField.jsx b/src/common/components/LinkField.jsx
new file mode 100644
index 00000000..08c6213a
--- /dev/null
+++ b/src/common/components/LinkField.jsx
@@ -0,0 +1,93 @@
+import { Autocomplete, TextField } from '@mui/material';
+import React, { useState } from 'react';
+import { useEffectAsync } from '../../reactHelper';
+
+const LinkField = ({
+ label,
+ endpointAll,
+ endpointLinked,
+ baseId,
+ keyBase,
+ keyLink,
+ keyGetter = (item) => item.id,
+ titleGetter = (item) => item.name,
+}) => {
+ const [active, setActive] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [items, setItems] = useState();
+ const [linked, setLinked] = useState();
+
+ useEffectAsync(async () => {
+ if (active) {
+ const response = await fetch(endpointAll);
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, [active]);
+
+ useEffectAsync(async () => {
+ if (active) {
+ const response = await fetch(endpointLinked);
+ if (response.ok) {
+ setLinked(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, [active]);
+
+ const createBody = (linkId) => {
+ const body = {};
+ body[keyBase] = baseId;
+ body[keyLink] = linkId;
+ return body;
+ };
+
+ const onChange = async (value) => {
+ const oldValue = linked.map((it) => keyGetter(it));
+ const newValue = value.map((it) => keyGetter(it));
+ if (!newValue.find((it) => it < 0)) {
+ const results = [];
+ newValue.filter((it) => !oldValue.includes(it)).forEach((added) => {
+ results.push(fetch('/api/permissions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(createBody(added)),
+ }));
+ });
+ oldValue.filter((it) => !newValue.includes(it)).forEach((removed) => {
+ results.push(fetch('/api/permissions', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(createBody(removed)),
+ }));
+ });
+ await Promise.all(results);
+ setLinked(value);
+ }
+ };
+
+ return (
+ <Autocomplete
+ loading={active && !items}
+ isOptionEqualToValue={(i1, i2) => keyGetter(i1) === keyGetter(i2)}
+ options={items || []}
+ getOptionLabel={(item) => titleGetter(item)}
+ renderInput={(params) => <TextField {...params} label={label} />}
+ value={(items && linked) || []}
+ onChange={(_, value) => onChange(value)}
+ open={open}
+ onOpen={() => {
+ setOpen(true);
+ setActive(true);
+ }}
+ onClose={() => setOpen(false)}
+ multiple
+ />
+ );
+};
+
+export default LinkField;
diff --git a/src/common/components/LocalizationProvider.jsx b/src/common/components/LocalizationProvider.jsx
new file mode 100644
index 00000000..4104c773
--- /dev/null
+++ b/src/common/components/LocalizationProvider.jsx
@@ -0,0 +1,187 @@
+/* eslint-disable import/no-relative-packages */
+import React, {
+ createContext, useContext, useEffect, useMemo,
+} from 'react';
+import dayjs from 'dayjs';
+import usePersistedState from '../util/usePersistedState';
+
+import af from '../../resources/l10n/af.json'; import 'dayjs/locale/af';
+import ar from '../../resources/l10n/ar.json'; import 'dayjs/locale/ar';
+import az from '../../resources/l10n/az.json'; import 'dayjs/locale/az';
+import bg from '../../resources/l10n/bg.json'; import 'dayjs/locale/bg';
+import bn from '../../resources/l10n/bn.json'; import 'dayjs/locale/bn';
+import ca from '../../resources/l10n/ca.json'; import 'dayjs/locale/ca';
+import cs from '../../resources/l10n/cs.json'; import 'dayjs/locale/cs';
+import da from '../../resources/l10n/da.json'; import 'dayjs/locale/da';
+import de from '../../resources/l10n/de.json'; import 'dayjs/locale/de';
+import el from '../../resources/l10n/el.json'; import 'dayjs/locale/el';
+import en from '../../resources/l10n/en.json'; import 'dayjs/locale/en';
+import es from '../../resources/l10n/es.json'; import 'dayjs/locale/es';
+import fa from '../../resources/l10n/fa.json'; import 'dayjs/locale/fa';
+import fi from '../../resources/l10n/fi.json'; import 'dayjs/locale/fi';
+import fr from '../../resources/l10n/fr.json'; import 'dayjs/locale/fr';
+import gl from '../../resources/l10n/gl.json'; import 'dayjs/locale/gl';
+import he from '../../resources/l10n/he.json'; import 'dayjs/locale/he';
+import hi from '../../resources/l10n/hi.json'; import 'dayjs/locale/hi';
+import hr from '../../resources/l10n/hr.json'; import 'dayjs/locale/hr';
+import hu from '../../resources/l10n/hu.json'; import 'dayjs/locale/hu';
+import id from '../../resources/l10n/id.json'; import 'dayjs/locale/id';
+import it from '../../resources/l10n/it.json'; import 'dayjs/locale/it';
+import ja from '../../resources/l10n/ja.json'; import 'dayjs/locale/ja';
+import ka from '../../resources/l10n/ka.json'; import 'dayjs/locale/ka';
+import kk from '../../resources/l10n/kk.json'; import 'dayjs/locale/kk';
+import km from '../../resources/l10n/km.json'; import 'dayjs/locale/km';
+import ko from '../../resources/l10n/ko.json'; import 'dayjs/locale/ko';
+import lo from '../../resources/l10n/lo.json'; import 'dayjs/locale/lo';
+import lt from '../../resources/l10n/lt.json'; import 'dayjs/locale/lt';
+import lv from '../../resources/l10n/lv.json'; import 'dayjs/locale/lv';
+import mk from '../../resources/l10n/mk.json'; import 'dayjs/locale/mk';
+import ml from '../../resources/l10n/ml.json'; import 'dayjs/locale/ml';
+import mn from '../../resources/l10n/mn.json'; import 'dayjs/locale/mn';
+import ms from '../../resources/l10n/ms.json'; import 'dayjs/locale/ms';
+import nb from '../../resources/l10n/nb.json'; import 'dayjs/locale/nb';
+import ne from '../../resources/l10n/ne.json'; import 'dayjs/locale/ne';
+import nl from '../../resources/l10n/nl.json'; import 'dayjs/locale/nl';
+import nn from '../../resources/l10n/nn.json'; import 'dayjs/locale/nn';
+import pl from '../../resources/l10n/pl.json'; import 'dayjs/locale/pl';
+import pt from '../../resources/l10n/pt.json'; import 'dayjs/locale/pt';
+import ptBR from '../../resources/l10n/pt_BR.json'; import 'dayjs/locale/pt-br';
+import ro from '../../resources/l10n/ro.json'; import 'dayjs/locale/ro';
+import ru from '../../resources/l10n/ru.json'; import 'dayjs/locale/ru';
+import si from '../../resources/l10n/si.json'; import 'dayjs/locale/si';
+import sk from '../../resources/l10n/sk.json'; import 'dayjs/locale/sk';
+import sl from '../../resources/l10n/sl.json'; import 'dayjs/locale/sl';
+import sq from '../../resources/l10n/sq.json'; import 'dayjs/locale/sq';
+import sr from '../../resources/l10n/sr.json'; import 'dayjs/locale/sr';
+import sv from '../../resources/l10n/sv.json'; import 'dayjs/locale/sv';
+import ta from '../../resources/l10n/ta.json'; import 'dayjs/locale/ta';
+import th from '../../resources/l10n/th.json'; import 'dayjs/locale/th';
+import tr from '../../resources/l10n/tr.json'; import 'dayjs/locale/tr';
+import uk from '../../resources/l10n/uk.json'; import 'dayjs/locale/uk';
+import uz from '../../resources/l10n/uz.json'; import 'dayjs/locale/uz';
+import vi from '../../resources/l10n/vi.json'; import 'dayjs/locale/vi';
+import zh from '../../resources/l10n/zh.json'; import 'dayjs/locale/zh';
+import zhTW from '../../resources/l10n/zh_TW.json'; import 'dayjs/locale/zh-tw';
+
+const languages = {
+ af: { data: af, country: 'ZA', name: 'Afrikaans' },
+ ar: { data: ar, country: 'AE', name: 'العربية' },
+ az: { data: az, country: 'AZ', name: 'Azərbaycanca' },
+ bg: { data: bg, country: 'BG', name: 'Български' },
+ bn: { data: bn, country: 'IN', name: 'বাংলা' },
+ ca: { data: ca, country: 'ES', name: 'Català' },
+ cs: { data: cs, country: 'CZ', name: 'Čeština' },
+ de: { data: de, country: 'DE', name: 'Deutsch' },
+ da: { data: da, country: 'DK', name: 'Dansk' },
+ el: { data: el, country: 'GR', name: 'Ελληνικά' },
+ en: { data: en, country: 'US', name: 'English' },
+ es: { data: es, country: 'ES', name: 'Español' },
+ fa: { data: fa, country: 'IR', name: 'فارسی' },
+ fi: { data: fi, country: 'FI', name: 'Suomi' },
+ fr: { data: fr, country: 'FR', name: 'Français' },
+ gl: { data: gl, country: 'ES', name: 'Galego' },
+ he: { data: he, country: 'IL', name: 'עברית' },
+ hi: { data: hi, country: 'IN', name: 'हिन्दी' },
+ hr: { data: hr, country: 'HR', name: 'Hrvatski' },
+ hu: { data: hu, country: 'HU', name: 'Magyar' },
+ id: { data: id, country: 'ID', name: 'Bahasa Indonesia' },
+ it: { data: it, country: 'IT', name: 'Italiano' },
+ ja: { data: ja, country: 'JP', name: '日本語' },
+ ka: { data: ka, country: 'GE', name: 'ქართული' },
+ kk: { data: kk, country: 'KZ', name: 'Қазақша' },
+ ko: { data: ko, country: 'KR', name: '한국어' },
+ km: { data: km, country: 'KH', name: 'ភាសាខ្មែរ' },
+ lo: { data: lo, country: 'LA', name: 'ລາວ' },
+ lt: { data: lt, country: 'LT', name: 'Lietuvių' },
+ lv: { data: lv, country: 'LV', name: 'Latviešu' },
+ mk: { data: mk, country: 'MK', name: 'Mакедонски' },
+ ml: { data: ml, country: 'IN', name: 'മലയാളം' },
+ mn: { data: mn, country: 'MN', name: 'Монгол хэл' },
+ ms: { data: ms, country: 'MY', name: 'بهاس ملايو' },
+ nb: { data: nb, country: 'NO', name: 'Norsk bokmål' },
+ ne: { data: ne, country: 'NP', name: 'नेपाली' },
+ nl: { data: nl, country: 'NL', name: 'Nederlands' },
+ nn: { data: nn, country: 'NO', name: 'Norsk nynorsk' },
+ pl: { data: pl, country: 'PL', name: 'Polski' },
+ pt: { data: pt, country: 'PT', name: 'Português' },
+ ptBR: { data: ptBR, country: 'BR', name: 'Português (Brasil)' },
+ ro: { data: ro, country: 'RO', name: 'Română' },
+ ru: { data: ru, country: 'RU', name: 'Русский' },
+ si: { data: si, country: 'LK', name: 'සිංහල' },
+ sk: { data: sk, country: 'SK', name: 'Slovenčina' },
+ sl: { data: sl, country: 'SI', name: 'Slovenščina' },
+ sq: { data: sq, country: 'AL', name: 'Shqipëria' },
+ sr: { data: sr, country: 'RS', name: 'Srpski' },
+ sv: { data: sv, country: 'SE', name: 'Svenska' },
+ ta: { data: ta, country: 'IN', name: 'தமிழ்' },
+ th: { data: th, country: 'TH', name: 'ไทย' },
+ tr: { data: tr, country: 'TR', name: 'Türkçe' },
+ uk: { data: uk, country: 'UA', name: 'Українська' },
+ uz: { data: uz, country: 'UZ', name: 'Oʻzbekcha' },
+ vi: { data: vi, country: 'VN', name: 'Tiếng Việt' },
+ zh: { data: zh, country: 'CN', name: '中文' },
+ zhTW: { data: zhTW, country: 'TW', name: '中文 (Taiwan)' },
+};
+
+const getDefaultLanguage = () => {
+ const browserLanguages = window.navigator.languages ? window.navigator.languages.slice() : [];
+ const browserLanguage = window.navigator.userLanguage || window.navigator.language;
+ browserLanguages.push(browserLanguage);
+ browserLanguages.push(browserLanguage.substring(0, 2));
+
+ for (let i = 0; i < browserLanguages.length; i += 1) {
+ let language = browserLanguages[i].replace('-', '');
+ if (language in languages) {
+ return language;
+ }
+ if (language.length > 2) {
+ language = language.substring(0, 2);
+ if (language in languages) {
+ return language;
+ }
+ }
+ }
+ return 'en';
+};
+
+const LocalizationContext = createContext({
+ languages,
+ language: 'en',
+ setLanguage: () => {},
+});
+
+export const LocalizationProvider = ({ children }) => {
+ const [language, setLanguage] = usePersistedState('language', getDefaultLanguage());
+
+ const value = useMemo(() => ({ languages, language, setLanguage }), [languages, language, setLanguage]);
+
+ useEffect(() => {
+ let selected;
+ if (language.length > 2) {
+ selected = `${language.slice(0, 2)}-${language.slice(-2).toLowerCase()}`;
+ } else {
+ selected = language;
+ }
+ dayjs.locale(selected);
+ }, [language]);
+
+ return (
+ <LocalizationContext.Provider value={value}>
+ {children}
+ </LocalizationContext.Provider>
+ );
+};
+
+export const useLocalization = () => useContext(LocalizationContext);
+
+export const useTranslation = () => {
+ const context = useContext(LocalizationContext);
+ const { data } = context.languages[context.language];
+ return useMemo(() => (key) => data[key], [data]);
+};
+
+export const useTranslationKeys = (predicate) => {
+ const context = useContext(LocalizationContext);
+ const { data } = context.languages[context.language];
+ return Object.keys(data).filter(predicate);
+};
diff --git a/src/common/components/NativeInterface.js b/src/common/components/NativeInterface.js
new file mode 100644
index 00000000..b088de0e
--- /dev/null
+++ b/src/common/components/NativeInterface.js
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useEffectAsync } from '../../reactHelper';
+import { sessionActions } from '../../store';
+
+export const nativeEnvironment = window.appInterface || (window.webkit && window.webkit.messageHandlers.appInterface);
+
+export const nativePostMessage = (message) => {
+ if (window.webkit && window.webkit.messageHandlers.appInterface) {
+ window.webkit.messageHandlers.appInterface.postMessage(message);
+ }
+ if (window.appInterface) {
+ window.appInterface.postMessage(message);
+ }
+};
+
+export const handleLoginTokenListeners = new Set();
+window.handleLoginToken = (token) => {
+ handleLoginTokenListeners.forEach((listener) => listener(token));
+};
+
+const updateNotificationTokenListeners = new Set();
+window.updateNotificationToken = (token) => {
+ updateNotificationTokenListeners.forEach((listener) => listener(token));
+};
+
+const NativeInterface = () => {
+ const dispatch = useDispatch();
+
+ const user = useSelector((state) => state.session.user);
+ const [notificationToken, setNotificationToken] = useState(null);
+
+ useEffect(() => {
+ const listener = (token) => setNotificationToken(token);
+ updateNotificationTokenListeners.add(listener);
+ return () => updateNotificationTokenListeners.delete(listener);
+ }, [setNotificationToken]);
+
+ useEffectAsync(async () => {
+ if (user && !user.readonly && notificationToken) {
+ window.localStorage.setItem('notificationToken', notificationToken);
+ setNotificationToken(null);
+
+ const tokens = user.attributes.notificationTokens?.split(',') || [];
+ if (!tokens.includes(notificationToken)) {
+ const updatedUser = {
+ ...user,
+ attributes: {
+ ...user.attributes,
+ notificationTokens: [...tokens.slice(-2), notificationToken].join(','),
+ },
+ };
+
+ const response = await fetch(`/api/users/${user.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updatedUser),
+ });
+
+ if (response.ok) {
+ dispatch(sessionActions.updateUser(await response.json()));
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }
+ }, [user, notificationToken, setNotificationToken]);
+
+ return null;
+};
+
+export default NativeInterface;
diff --git a/src/common/components/NavBar.jsx b/src/common/components/NavBar.jsx
new file mode 100644
index 00000000..a53960fd
--- /dev/null
+++ b/src/common/components/NavBar.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import {
+ AppBar, Toolbar, Typography, IconButton,
+} from '@mui/material';
+import MenuIcon from '@mui/icons-material/Menu';
+
+const Navbar = ({ setOpenDrawer, title }) => (
+ <AppBar position="sticky" color="inherit">
+ <Toolbar>
+ <IconButton
+ color="inherit"
+ edge="start"
+ sx={{ mr: 2 }}
+ onClick={() => setOpenDrawer(true)}
+ >
+ <MenuIcon />
+ </IconButton>
+ <Typography variant="h6" noWrap>
+ {title}
+ </Typography>
+ </Toolbar>
+ </AppBar>
+);
+
+export default Navbar;
diff --git a/src/common/components/PageLayout.jsx b/src/common/components/PageLayout.jsx
new file mode 100644
index 00000000..e81c9754
--- /dev/null
+++ b/src/common/components/PageLayout.jsx
@@ -0,0 +1,118 @@
+import React, { useState } from 'react';
+import {
+ AppBar,
+ Breadcrumbs,
+ Divider,
+ Drawer,
+ IconButton,
+ Toolbar,
+ Typography,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import MenuIcon from '@mui/icons-material/Menu';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from './LocalizationProvider';
+
+const useStyles = makeStyles((theme) => ({
+ desktopRoot: {
+ height: '100%',
+ display: 'flex',
+ },
+ mobileRoot: {
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ desktopDrawer: {
+ width: theme.dimensions.drawerWidthDesktop,
+ },
+ mobileDrawer: {
+ width: theme.dimensions.drawerWidthTablet,
+ },
+ mobileToolbar: {
+ zIndex: 1,
+ },
+ content: {
+ flexGrow: 1,
+ alignItems: 'stretch',
+ display: 'flex',
+ flexDirection: 'column',
+ overflowY: 'auto',
+ },
+}));
+
+const PageTitle = ({ breadcrumbs }) => {
+ const theme = useTheme();
+ const t = useTranslation();
+
+ const desktop = useMediaQuery(theme.breakpoints.up('md'));
+
+ if (desktop) {
+ return (
+ <Typography variant="h6" noWrap>{t(breadcrumbs[0])}</Typography>
+ );
+ }
+ return (
+ <Breadcrumbs>
+ {breadcrumbs.slice(0, -1).map((breadcrumb) => (
+ <Typography variant="h6" color="inherit" key={breadcrumb}>{t(breadcrumb)}</Typography>
+ ))}
+ <Typography variant="h6" color="textPrimary">{t(breadcrumbs[breadcrumbs.length - 1])}</Typography>
+ </Breadcrumbs>
+ );
+};
+
+const PageLayout = ({ menu, breadcrumbs, children }) => {
+ const classes = useStyles();
+ const theme = useTheme();
+ const navigate = useNavigate();
+
+ const desktop = useMediaQuery(theme.breakpoints.up('md'));
+
+ const [openDrawer, setOpenDrawer] = useState(false);
+
+ return desktop ? (
+ <div className={classes.desktopRoot}>
+ <Drawer
+ variant="permanent"
+ className={classes.desktopDrawer}
+ classes={{ paper: classes.desktopDrawer }}
+ >
+ <Toolbar>
+ <IconButton color="inherit" edge="start" sx={{ mr: 2 }} onClick={() => navigate('/')}>
+ <ArrowBackIcon />
+ </IconButton>
+ <PageTitle breadcrumbs={breadcrumbs} />
+ </Toolbar>
+ <Divider />
+ {menu}
+ </Drawer>
+ <div className={classes.content}>{children}</div>
+ </div>
+ ) : (
+ <div className={classes.mobileRoot}>
+ <Drawer
+ variant="temporary"
+ open={openDrawer}
+ onClose={() => setOpenDrawer(false)}
+ classes={{ paper: classes.mobileDrawer }}
+ >
+ {menu}
+ </Drawer>
+ <AppBar className={classes.mobileToolbar} position="static" color="inherit">
+ <Toolbar>
+ <IconButton color="inherit" edge="start" sx={{ mr: 2 }} onClick={() => setOpenDrawer(true)}>
+ <MenuIcon />
+ </IconButton>
+ <PageTitle breadcrumbs={breadcrumbs} />
+ </Toolbar>
+ </AppBar>
+ <div className={classes.content}>{children}</div>
+ </div>
+ );
+};
+
+export default PageLayout;
diff --git a/src/common/components/PositionValue.jsx b/src/common/components/PositionValue.jsx
new file mode 100644
index 00000000..b1f8f656
--- /dev/null
+++ b/src/common/components/PositionValue.jsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { Link } from '@mui/material';
+import { Link as RouterLink } from 'react-router-dom';
+import {
+ formatAlarm,
+ formatAltitude,
+ formatBoolean,
+ formatCoordinate,
+ formatCourse,
+ formatDistance,
+ formatNumber,
+ formatNumericHours,
+ formatPercentage,
+ formatSpeed,
+ formatTime,
+ formatTemperature,
+ formatVoltage,
+ formatVolume,
+ formatConsumption,
+} from '../util/formatter';
+import { speedToKnots } from '../util/converter';
+import { useAttributePreference, usePreference } from '../util/preferences';
+import { useTranslation } from './LocalizationProvider';
+import { useAdministrator } from '../util/permissions';
+import AddressValue from './AddressValue';
+import GeofencesValue from './GeofencesValue';
+import DriverValue from './DriverValue';
+
+const PositionValue = ({ position, property, attribute }) => {
+ const t = useTranslation();
+
+ const admin = useAdministrator();
+
+ const device = useSelector((state) => state.devices.items[position.deviceId]);
+
+ const key = property || attribute;
+ const value = property ? position[property] : position.attributes[attribute];
+
+ const distanceUnit = useAttributePreference('distanceUnit');
+ const altitudeUnit = useAttributePreference('altitudeUnit');
+ const speedUnit = useAttributePreference('speedUnit');
+ const volumeUnit = useAttributePreference('volumeUnit');
+ const coordinateFormat = usePreference('coordinateFormat');
+ const hours12 = usePreference('twelveHourFormat');
+
+ const formatValue = () => {
+ switch (key) {
+ case 'fixTime':
+ case 'deviceTime':
+ case 'serverTime':
+ return formatTime(value, 'seconds', hours12);
+ case 'latitude':
+ return formatCoordinate('latitude', value, coordinateFormat);
+ case 'longitude':
+ return formatCoordinate('longitude', value, coordinateFormat);
+ case 'speed':
+ return value != null ? formatSpeed(value, speedUnit, t) : '';
+ case 'obdSpeed':
+ return value != null ? formatSpeed(speedToKnots(value, 'kmh'), speedUnit, t) : '';
+ case 'course':
+ return formatCourse(value);
+ case 'altitude':
+ return formatAltitude(value, altitudeUnit, t);
+ case 'power':
+ case 'battery':
+ return formatVoltage(value, t);
+ case 'batteryLevel':
+ return value != null ? formatPercentage(value, t) : '';
+ case 'volume':
+ return value != null ? formatVolume(value, volumeUnit, t) : '';
+ case 'fuelConsumption':
+ return value != null ? formatConsumption(value, t) : '';
+ case 'coolantTemp':
+ return formatTemperature(value);
+ case 'alarm':
+ return formatAlarm(value, t);
+ case 'odometer':
+ case 'serviceOdometer':
+ case 'tripOdometer':
+ case 'obdOdometer':
+ case 'distance':
+ case 'totalDistance':
+ return value != null ? formatDistance(value, distanceUnit, t) : '';
+ case 'hours':
+ return value != null ? formatNumericHours(value, t) : '';
+ default:
+ if (typeof value === 'number') {
+ return formatNumber(value);
+ } if (typeof value === 'boolean') {
+ return formatBoolean(value, t);
+ }
+ return value || '';
+ }
+ };
+
+ switch (key) {
+ case 'image':
+ case 'video':
+ case 'audio':
+ return <Link href={`/api/media/${device.uniqueId}/${value}`} target="_blank">{value}</Link>;
+ case 'totalDistance':
+ case 'hours':
+ return (
+ <>
+ {formatValue(value)}
+ &nbsp;&nbsp;
+ {admin && <Link component={RouterLink} underline="none" to={`/settings/accumulators/${position.deviceId}`}>&#9881;</Link>}
+ </>
+ );
+ case 'address':
+ return <AddressValue latitude={position.latitude} longitude={position.longitude} originalAddress={value} />;
+ case 'network':
+ if (value) {
+ return <Link component={RouterLink} underline="none" to={`/network/${position.id}`}>{t('sharedInfoTitle')}</Link>;
+ }
+ return '';
+ case 'geofenceIds':
+ if (value) {
+ return <GeofencesValue geofenceIds={value} />;
+ }
+ return '';
+ case 'driverUniqueId':
+ if (value) {
+ return <DriverValue driverUniqueId={value} />;
+ }
+ return '';
+ default:
+ return formatValue(value);
+ }
+};
+
+export default PositionValue;
diff --git a/src/common/components/RemoveDialog.jsx b/src/common/components/RemoveDialog.jsx
new file mode 100644
index 00000000..0f4254a8
--- /dev/null
+++ b/src/common/components/RemoveDialog.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import Button from '@mui/material/Button';
+import { Snackbar } from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import { useTranslation } from './LocalizationProvider';
+import { useCatch } from '../../reactHelper';
+import { snackBarDurationLongMs } from '../util/duration';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ [theme.breakpoints.down('md')]: {
+ bottom: `calc(${theme.dimensions.bottomBarHeight}px + ${theme.spacing(1)})`,
+ },
+ },
+ button: {
+ height: 'auto',
+ marginTop: 0,
+ marginBottom: 0,
+ color: theme.palette.error.main,
+ },
+}));
+
+const RemoveDialog = ({
+ open, endpoint, itemId, onResult,
+}) => {
+ const classes = useStyles();
+ const t = useTranslation();
+
+ const handleRemove = useCatch(async () => {
+ const response = await fetch(`/api/${endpoint}/${itemId}`, { method: 'DELETE' });
+ if (response.ok) {
+ onResult(true);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <Snackbar
+ className={classes.root}
+ open={open}
+ autoHideDuration={snackBarDurationLongMs}
+ onClose={() => onResult(false)}
+ message={t('sharedRemoveConfirm')}
+ action={(
+ <Button size="small" className={classes.button} onClick={handleRemove}>
+ {t('sharedRemove')}
+ </Button>
+ )}
+ />
+ );
+};
+
+export default RemoveDialog;
diff --git a/src/common/components/SelectField.jsx b/src/common/components/SelectField.jsx
new file mode 100644
index 00000000..db8c30b0
--- /dev/null
+++ b/src/common/components/SelectField.jsx
@@ -0,0 +1,77 @@
+import {
+ FormControl, InputLabel, MenuItem, Select, Autocomplete, TextField,
+} from '@mui/material';
+import React, { useState } from 'react';
+import { useEffectAsync } from '../../reactHelper';
+
+const SelectField = ({
+ label,
+ fullWidth,
+ multiple,
+ value = null,
+ emptyValue = null,
+ emptyTitle = '',
+ onChange,
+ endpoint,
+ data,
+ keyGetter = (item) => item.id,
+ titleGetter = (item) => item.name,
+}) => {
+ const [items, setItems] = useState(data);
+
+ const getOptionLabel = (option) => {
+ if (typeof option !== 'object') {
+ option = items.find((obj) => keyGetter(obj) === option);
+ }
+ return option ? titleGetter(option) : emptyTitle;
+ };
+
+ useEffectAsync(async () => {
+ if (endpoint) {
+ const response = await fetch(endpoint);
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, []);
+
+ if (items) {
+ return (
+ <FormControl fullWidth={fullWidth}>
+ {multiple ? (
+ <>
+ <InputLabel>{label}</InputLabel>
+ <Select
+ label={label}
+ multiple
+ value={value}
+ onChange={onChange}
+ >
+ {items.map((item) => (
+ <MenuItem key={keyGetter(item)} value={keyGetter(item)}>{titleGetter(item)}</MenuItem>
+ ))}
+ </Select>
+ </>
+ ) : (
+ <Autocomplete
+ size="small"
+ options={items}
+ getOptionLabel={getOptionLabel}
+ renderOption={(props, option) => (
+ <MenuItem {...props} key={keyGetter(option)} value={keyGetter(option)}>{titleGetter(option)}</MenuItem>
+ )}
+ isOptionEqualToValue={(option, value) => keyGetter(option) === value}
+ value={value}
+ onChange={(_, value) => onChange({ target: { value: value ? keyGetter(value) : emptyValue } })}
+ renderInput={(params) => <TextField {...params} label={label} />}
+ />
+ )}
+ </FormControl>
+ );
+ }
+ return null;
+};
+
+export default SelectField;
diff --git a/src/common/components/SideNav.jsx b/src/common/components/SideNav.jsx
new file mode 100644
index 00000000..97968bd1
--- /dev/null
+++ b/src/common/components/SideNav.jsx
@@ -0,0 +1,33 @@
+import React, { Fragment } from 'react';
+import {
+ List, ListItemText, ListItemIcon, Divider, ListSubheader, ListItemButton,
+} from '@mui/material';
+import { Link, useLocation } from 'react-router-dom';
+
+const SideNav = ({ routes }) => {
+ const location = useLocation();
+
+ return (
+ <List disablePadding style={{ paddingTop: '16px' }}>
+ {routes.map((route) => (route.subheader ? (
+ <Fragment key={route.subheader}>
+ <Divider />
+ <ListSubheader>{route.subheader}</ListSubheader>
+ </Fragment>
+ ) : (
+ <ListItemButton
+ disableRipple
+ component={Link}
+ key={route.href}
+ to={route.href}
+ selected={location.pathname.match(route.match || route.href) !== null}
+ >
+ <ListItemIcon>{route.icon}</ListItemIcon>
+ <ListItemText primary={route.name} />
+ </ListItemButton>
+ )))}
+ </List>
+ );
+};
+
+export default SideNav;
diff --git a/src/common/components/SplitButton.jsx b/src/common/components/SplitButton.jsx
new file mode 100644
index 00000000..84876f15
--- /dev/null
+++ b/src/common/components/SplitButton.jsx
@@ -0,0 +1,48 @@
+import React, { useRef, useState } from 'react';
+import {
+ Button, ButtonGroup, Menu, MenuItem, Typography,
+} from '@mui/material';
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+
+const SplitButton = ({
+ fullWidth, variant, color, disabled, onClick, options, selected, setSelected,
+}) => {
+ const anchorRef = useRef();
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
+
+ return (
+ <>
+ <ButtonGroup fullWidth={fullWidth} variant={variant} color={color} ref={anchorRef}>
+ <Button disabled={disabled} onClick={() => onClick(selected)}>
+ <Typography variant="button" noWrap>{options[selected]}</Typography>
+ </Button>
+ <Button fullWidth={false} size="small" onClick={() => setMenuAnchorEl(anchorRef.current)}>
+ <ArrowDropDownIcon />
+ </Button>
+ </ButtonGroup>
+ <Menu
+ open={!!menuAnchorEl}
+ anchorEl={menuAnchorEl}
+ onClose={() => setMenuAnchorEl(null)}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'right',
+ }}
+ >
+ {Object.entries(options).map(([key, value]) => (
+ <MenuItem
+ key={key}
+ onClick={() => {
+ setSelected(key);
+ setMenuAnchorEl(null);
+ }}
+ >
+ {value}
+ </MenuItem>
+ ))}
+ </Menu>
+ </>
+ );
+};
+
+export default SplitButton;
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;
diff --git a/src/common/components/TableShimmer.jsx b/src/common/components/TableShimmer.jsx
new file mode 100644
index 00000000..08a984a4
--- /dev/null
+++ b/src/common/components/TableShimmer.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Skeleton, TableCell, TableRow } from '@mui/material';
+
+const TableShimmer = ({ columns, startAction, endAction }) => [...Array(3)].map((_, i) => (
+ <TableRow key={-i}>
+ {[...Array(columns)].map((_, j) => {
+ const action = (startAction && j === 0) || (endAction && j === columns - 1);
+ return (
+ <TableCell key={-j} padding={action ? 'none' : 'normal'}>
+ {!action && <Skeleton />}
+ </TableCell>
+ );
+ })}
+ </TableRow>
+));
+
+export default TableShimmer;