diff options
author | Anton Tananaev <anton@traccar.org> | 2024-04-06 09:22:10 -0700 |
---|---|---|
committer | Anton Tananaev <anton@traccar.org> | 2024-04-06 09:22:10 -0700 |
commit | f418231b6b2f5e030a0d2dcc390c314602b1f740 (patch) | |
tree | 10326adf3792bc2697e06bb5f2b8ef2a8f7e55fe /src/common/components | |
parent | b392a4af78e01c8e0f50aad5468e9583675b24be (diff) | |
download | trackermap-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.jsx | 37 | ||||
-rw-r--r-- | src/common/components/BottomMenu.jsx | 135 | ||||
-rw-r--r-- | src/common/components/DriverValue.js | 9 | ||||
-rw-r--r-- | src/common/components/ErrorHandler.jsx | 27 | ||||
-rw-r--r-- | src/common/components/GeofencesValue.js | 9 | ||||
-rw-r--r-- | src/common/components/LinkField.jsx | 93 | ||||
-rw-r--r-- | src/common/components/LocalizationProvider.jsx | 187 | ||||
-rw-r--r-- | src/common/components/NativeInterface.js | 72 | ||||
-rw-r--r-- | src/common/components/NavBar.jsx | 25 | ||||
-rw-r--r-- | src/common/components/PageLayout.jsx | 118 | ||||
-rw-r--r-- | src/common/components/PositionValue.jsx | 133 | ||||
-rw-r--r-- | src/common/components/RemoveDialog.jsx | 54 | ||||
-rw-r--r-- | src/common/components/SelectField.jsx | 77 | ||||
-rw-r--r-- | src/common/components/SideNav.jsx | 33 | ||||
-rw-r--r-- | src/common/components/SplitButton.jsx | 48 | ||||
-rw-r--r-- | src/common/components/StatusCard.jsx | 288 | ||||
-rw-r--r-- | src/common/components/TableShimmer.jsx | 17 |
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)} + + {admin && <Link component={RouterLink} underline="none" to={`/settings/accumulators/${position.deviceId}`}>⚙</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; |