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 | |
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')
40 files changed, 2805 insertions, 0 deletions
diff --git a/src/common/attributes/useCommandAttributes.js b/src/common/attributes/useCommandAttributes.js new file mode 100644 index 00000000..189a0e2e --- /dev/null +++ b/src/common/attributes/useCommandAttributes.js @@ -0,0 +1,213 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + custom: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + positionPeriodic: [ + { + key: 'frequency', + name: t('commandFrequency'), + type: 'number', + }, + ], + setTimezone: [ + { + key: 'timezone', + name: t('commandTimezone'), + type: 'string', + }, + ], + sendSms: [ + { + key: 'phone', + name: t('commandPhone'), + type: 'string', + }, + { + key: 'message', + name: t('commandMessage'), + type: 'string', + }, + ], + message: [ + { + key: 'message', + name: t('commandMessage'), + type: 'string', + }, + ], + sendUssd: [ + { + key: 'phone', + name: t('commandPhone'), + type: 'string', + }, + ], + sosNumber: [ + { + key: 'index', + name: t('commandIndex'), + type: 'number', + }, + { + key: 'phone', + name: t('commandPhone'), + type: 'string', + }, + ], + silenceTime: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + setPhonebook: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + voiceMessage: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + outputControl: [ + { + key: 'index', + name: t('commandIndex'), + type: 'number', + }, + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + voiceMonitoring: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + setAgps: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + setIndicator: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + configuration: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + setConnection: [ + { + key: 'server', + name: t('commandServer'), + type: 'string', + }, + { + key: 'port', + name: t('commandPort'), + type: 'number', + }, + ], + setOdometer: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + modePowerSaving: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + modeDeepSleep: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + alarmGeofence: [ + { + key: 'radius', + name: t('commandRadius'), + type: 'number', + }, + ], + alarmBattery: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + alarmSos: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + alarmRemove: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + alarmClock: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + alarmSpeed: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], + alarmFall: [ + { + key: 'enable', + name: t('commandEnable'), + type: 'boolean', + }, + ], + alarmVibration: [ + { + key: 'data', + name: t('commandData'), + type: 'string', + }, + ], +}), [t]); diff --git a/src/common/attributes/useCommonDeviceAttributes.js b/src/common/attributes/useCommonDeviceAttributes.js new file mode 100644 index 00000000..f9214818 --- /dev/null +++ b/src/common/attributes/useCommonDeviceAttributes.js @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + speedLimit: { + name: t('attributeSpeedLimit'), + type: 'number', + subtype: 'speed', + }, + fuelDropThreshold: { + name: t('attributeFuelDropThreshold'), + type: 'number', + }, + fuelIncreaseThreshold: { + name: t('attributeFuelIncreaseThreshold'), + type: 'number', + }, + 'report.ignoreOdometer': { + name: t('attributeReportIgnoreOdometer'), + type: 'boolean', + }, +}), [t]); diff --git a/src/common/attributes/useCommonUserAttributes.js b/src/common/attributes/useCommonUserAttributes.js new file mode 100644 index 00000000..294ddea8 --- /dev/null +++ b/src/common/attributes/useCommonUserAttributes.js @@ -0,0 +1,136 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + mapGeofences: { + name: t('attributeShowGeofences'), + type: 'boolean', + }, + mapLiveRoutes: { + name: t('mapLiveRoutes'), + type: 'string', + }, + mapDirection: { + name: t('mapDirection'), + type: 'string', + }, + mapFollow: { + name: t('deviceFollow'), + type: 'boolean', + }, + mapCluster: { + name: t('mapClustering'), + type: 'boolean', + }, + mapOnSelect: { + name: t('mapOnSelect'), + type: 'boolean', + }, + activeMapStyles: { + name: t('mapActive'), + type: 'string', + }, + devicePrimary: { + name: t('devicePrimaryInfo'), + type: 'string', + }, + deviceSecondary: { + name: t('deviceSecondaryInfo'), + type: 'string', + }, + soundEvents: { + name: t('eventsSoundEvents'), + type: 'string', + }, + soundAlarms: { + name: t('eventsSoundAlarms'), + type: 'string', + }, + positionItems: { + name: t('attributePopupInfo'), + type: 'string', + }, + locationIqKey: { + name: t('mapLocationIqKey'), + type: 'string', + }, + mapboxAccessToken: { + name: t('mapMapboxKey'), + type: 'string', + }, + mapTilerKey: { + name: t('mapMapTilerKey'), + type: 'string', + }, + bingMapsKey: { + name: t('mapBingKey'), + type: 'string', + }, + openWeatherKey: { + name: t('mapOpenWeatherKey'), + type: 'string', + }, + tomTomKey: { + name: t('mapTomTomKey'), + type: 'string', + }, + hereKey: { + name: t('mapHereKey'), + type: 'string', + }, + notificationTokens: { + name: t('attributeNotificationTokens'), + type: 'string', + }, + 'ui.disableSavedCommands': { + name: t('attributeUiDisableSavedCommands'), + type: 'boolean', + }, + 'ui.disableGroups': { + name: t('attributeUiDisableGroups'), + type: 'boolean', + }, + 'ui.disableAttributes': { + name: t('attributeUiDisableAttributes'), + type: 'boolean', + }, + 'ui.disableEvents': { + name: t('attributeUiDisableEvents'), + type: 'boolean', + }, + 'ui.disableVehicleFeatures': { + name: t('attributeUiDisableVehicleFeatures'), + type: 'boolean', + }, + 'ui.disableDrivers': { + name: t('attributeUiDisableDrivers'), + type: 'boolean', + }, + 'ui.disableComputedAttributes': { + name: t('attributeUiDisableComputedAttributes'), + type: 'boolean', + }, + 'ui.disableCalendars': { + name: t('attributeUiDisableCalendars'), + type: 'boolean', + }, + 'ui.disableMaintenance': { + name: t('attributeUiDisableMaintenance'), + type: 'boolean', + }, + 'web.liveRouteLength': { + name: t('attributeWebLiveRouteLength'), + type: 'number', + }, + 'web.selectZoom': { + name: t('attributeWebSelectZoom'), + type: 'number', + }, + 'web.maxZoom': { + name: t('attributeWebMaxZoom'), + type: 'number', + }, + iconScale: { + name: t('sharedIconScale'), + type: 'number', + }, +}), [t]); diff --git a/src/common/attributes/useDeviceAttributes.js b/src/common/attributes/useDeviceAttributes.js new file mode 100644 index 00000000..eab9b8f6 --- /dev/null +++ b/src/common/attributes/useDeviceAttributes.js @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + 'web.reportColor': { + name: t('attributeWebReportColor'), + type: 'string', + subtype: 'color', + }, + devicePassword: { + name: t('attributeDevicePassword'), + type: 'string', + }, + deviceImage: { + name: t('attributeDeviceImage'), + type: 'string', + }, + 'processing.copyAttributes': { + name: t('attributeProcessingCopyAttributes'), + type: 'string', + }, + 'decoder.timezone': { + name: t('sharedTimezone'), + type: 'string', + }, + deviceInactivityStart: { + name: t('attributeDeviceInactivityStart'), + type: 'number', + }, + deviceInactivityPeriod: { + name: t('attributeDeviceInactivityPeriod'), + type: 'number', + }, +}), [t]); diff --git a/src/common/attributes/useGeofenceAttributes.js b/src/common/attributes/useGeofenceAttributes.js new file mode 100644 index 00000000..a5cd068b --- /dev/null +++ b/src/common/attributes/useGeofenceAttributes.js @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + color: { + name: t('attributeColor'), + type: 'string', + }, + speedLimit: { + name: t('attributeSpeedLimit'), + type: 'number', + subtype: 'speed', + }, + polylineDistance: { + name: t('attributePolylineDistance'), + type: 'number', + subtype: 'distance', + }, +}), [t]); diff --git a/src/common/attributes/useGroupAttributes.js b/src/common/attributes/useGroupAttributes.js new file mode 100644 index 00000000..53a299e1 --- /dev/null +++ b/src/common/attributes/useGroupAttributes.js @@ -0,0 +1,12 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + 'processing.copyAttributes': { + name: t('attributeProcessingCopyAttributes'), + type: 'string', + }, + 'decoder.timezone': { + name: t('sharedTimezone'), + type: 'string', + }, +}), [t]); diff --git a/src/common/attributes/usePositionAttributes.js b/src/common/attributes/usePositionAttributes.js new file mode 100644 index 00000000..0b191ebc --- /dev/null +++ b/src/common/attributes/usePositionAttributes.js @@ -0,0 +1,380 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + id: { + name: t('deviceIdentifier'), + type: 'number', + property: true, + }, + latitude: { + name: t('positionLatitude'), + type: 'number', + property: true, + }, + longitude: { + name: t('positionLongitude'), + type: 'number', + property: true, + }, + speed: { + name: t('positionSpeed'), + type: 'number', + dataType: 'speed', + property: true, + }, + course: { + name: t('positionCourse'), + type: 'number', + property: true, + }, + altitude: { + name: t('positionAltitude'), + type: 'number', + property: true, + }, + accuracy: { + name: t('positionAccuracy'), + type: 'number', + dataType: 'distance', + property: true, + }, + valid: { + name: t('positionValid'), + type: 'boolean', + property: true, + }, + protocol: { + name: t('positionProtocol'), + type: 'string', + property: true, + }, + address: { + name: t('positionAddress'), + type: 'string', + property: true, + }, + deviceTime: { + name: t('positionDeviceTime'), + type: 'string', + property: true, + }, + fixTime: { + name: t('positionFixTime'), + type: 'string', + property: true, + }, + serverTime: { + name: t('positionServerTime'), + type: 'string', + property: true, + }, + geofenceIds: { + name: t('sharedGeofences'), + property: true, + }, + raw: { + name: t('positionRaw'), + type: 'string', + }, + index: { + name: t('positionIndex'), + type: 'number', + }, + hdop: { + name: t('positionHdop'), + type: 'number', + }, + vdop: { + name: t('positionVdop'), + type: 'number', + }, + pdop: { + name: t('positionPdop'), + type: 'number', + }, + sat: { + name: t('positionSat'), + type: 'number', + }, + satVisible: { + name: t('positionSatVisible'), + type: 'number', + }, + rssi: { + name: t('positionRssi'), + type: 'number', + }, + coolantTemp: { + name: t('positionCoolantTemp'), + type: 'number', + }, + gps: { + name: t('positionGps'), + type: 'number', + }, + roaming: { + name: t('positionRoaming'), + type: 'boolean', + }, + event: { + name: t('positionEvent'), + type: 'string', + }, + alarm: { + name: t('positionAlarm'), + type: 'string', + }, + status: { + name: t('positionStatus'), + type: 'string', + }, + odometer: { + name: t('positionOdometer'), + type: 'number', + dataType: 'distance', + }, + serviceOdometer: { + name: t('positionServiceOdometer'), + type: 'number', + dataType: 'distance', + }, + tripOdometer: { + name: t('positionTripOdometer'), + type: 'number', + dataType: 'distance', + }, + hours: { + name: t('positionHours'), + type: 'number', + dataType: 'hours', + }, + steps: { + name: t('positionSteps'), + type: 'number', + }, + heartRate: { + name: t('positionHeartRate'), + type: 'number', + }, + input: { + name: t('positionInput'), + type: 'number', + }, + in1: { + name: `${t('positionInput')} 1`, + type: 'boolean', + }, + in2: { + name: `${t('positionInput')} 2`, + type: 'boolean', + }, + in3: { + name: `${t('positionInput')} 3`, + type: 'boolean', + }, + in4: { + name: `${t('positionInput')} 4`, + type: 'boolean', + }, + output: { + name: t('positionOutput'), + type: 'number', + }, + out1: { + name: `${t('positionOutput')} 1`, + type: 'boolean', + }, + out2: { + name: `${t('positionOutput')} 2`, + type: 'boolean', + }, + out3: { + name: `${t('positionOutput')} 3`, + type: 'boolean', + }, + out4: { + name: `${t('positionOutput')} 4`, + type: 'boolean', + }, + power: { + name: t('positionPower'), + type: 'number', + dataType: 'voltage', + }, + battery: { + name: t('positionBattery'), + type: 'number', + dataType: 'voltage', + }, + batteryLevel: { + name: t('positionBatteryLevel'), + type: 'number', + dataType: 'percentage', + }, + fuel: { + name: t('positionFuel'), + type: 'number', + dataType: 'volume', + }, + fuelConsumption: { + name: t('positionFuelConsumption'), + type: 'number', + }, + versionFw: { + name: t('positionVersionFw'), + type: 'string', + }, + versionHw: { + name: t('positionVersionHw'), + type: 'string', + }, + type: { + name: t('sharedType'), + type: 'string', + }, + ignition: { + name: t('positionIgnition'), + type: 'boolean', + }, + flags: { + name: t('positionFlags'), + type: 'string', + }, + charge: { + name: t('positionCharge'), + type: 'boolean', + }, + ip: { + name: t('positionIp'), + type: 'string', + }, + archive: { + name: t('positionArchive'), + type: 'boolean', + }, + distance: { + name: t('positionDistance'), + type: 'number', + dataType: 'distance', + }, + totalDistance: { + name: t('deviceTotalDistance'), + type: 'number', + dataType: 'distance', + }, + rpm: { + name: t('positionRpm'), + type: 'number', + }, + vin: { + name: t('positionVin'), + type: 'string', + }, + approximate: { + name: t('positionApproximate'), + type: 'boolean', + }, + throttle: { + name: t('positionThrottle'), + type: 'number', + }, + motion: { + name: t('positionMotion'), + type: 'boolean', + }, + armed: { + name: t('positionArmed'), + type: 'boolean', + }, + geofence: { + name: t('sharedGeofence'), + type: 'string', + }, + acceleration: { + name: t('positionAcceleration'), + type: 'number', + }, + deviceTemp: { + name: t('positionDeviceTemp'), + type: 'number', + }, + temp1: { + name: `${t('positionTemp')} 1`, + type: 'number', + }, + temp2: { + name: `${t('positionTemp')} 2`, + type: 'number', + }, + temp3: { + name: `${t('positionTemp')} 3`, + type: 'number', + }, + temp4: { + name: `${t('positionTemp')} 4`, + type: 'number', + }, + operator: { + name: t('positionOperator'), + type: 'string', + }, + command: { + name: t('deviceCommand'), + type: 'string', + }, + blocked: { + name: t('positionBlocked'), + type: 'boolean', + }, + lock: { + name: t('alarmLock'), + type: 'boolean', + }, + dtcs: { + name: t('positionDtcs'), + type: 'string', + }, + obdSpeed: { + name: t('positionObdSpeed'), + type: 'number', + dataType: 'speed', + }, + obdOdometer: { + name: t('positionObdOdometer'), + type: 'number', + dataType: 'distance', + }, + result: { + name: t('eventCommandResult'), + type: 'string', + }, + driverUniqueId: { + name: t('sharedDriver'), + type: 'string', + }, + card: { + name: t('positionCard'), + type: 'string', + }, + drivingTime: { + name: t('positionDrivingTime'), + type: 'number', + dataType: 'hours', + }, + color: { + name: t('attributeColor'), + type: 'string', + }, + image: { + name: t('positionImage'), + type: 'string', + }, + video: { + name: t('positionVideo'), + type: 'string', + }, + audio: { + name: t('positionAudio'), + type: 'string', + }, +}), [t]); diff --git a/src/common/attributes/useServerAttributes.js b/src/common/attributes/useServerAttributes.js new file mode 100644 index 00000000..80ac3c7d --- /dev/null +++ b/src/common/attributes/useServerAttributes.js @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + support: { + name: t('settingsSupport'), + type: 'string', + }, + title: { + name: t('serverName'), + type: 'string', + }, + description: { + name: t('serverDescription'), + type: 'string', + }, + logo: { + name: t('serverLogo'), + type: 'string', + }, + logoInverted: { + name: t('serverLogoInverted'), + type: 'string', + }, + colorPrimary: { + name: t('serverColorPrimary'), + type: 'string', + subtype: 'color', + }, + colorSecondary: { + name: t('serverColorSecondary'), + type: 'string', + subtype: 'color', + }, + disableChange: { + name: t('serverChangeDisable'), + type: 'boolean', + }, + darkMode: { + name: t('settingsDarkMode'), + type: 'boolean', + }, + totpEnable: { + name: t('settingsTotpEnable'), + type: 'boolean', + }, + totpForce: { + name: t('settingsTotpForce'), + type: 'boolean', + }, + serviceWorkerUpdateInterval: { + name: t('settingsServiceWorkerUpdateInterval'), + type: 'number', + }, + 'ui.disableLoginLanguage': { + name: t('attributeUiDisableLoginLanguage'), + type: 'boolean', + }, + disableShare: { + name: t('serverDisableShare'), + type: 'boolean', + }, +}), [t]); diff --git a/src/common/attributes/useUserAttributes.js b/src/common/attributes/useUserAttributes.js new file mode 100644 index 00000000..81230884 --- /dev/null +++ b/src/common/attributes/useUserAttributes.js @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; + +export default (t) => useMemo(() => ({ + telegramChatId: { + name: t('attributeTelegramChatId'), + type: 'string', + }, + pushoverUserKey: { + name: t('attributePushoverUserKey'), + type: 'string', + }, + pushoverDeviceNames: { + name: t('attributePushoverDeviceNames'), + type: 'string', + }, + 'mail.smtp.host': { + name: t('attributeMailSmtpHost'), + type: 'string', + }, + 'mail.smtp.port': { + name: t('attributeMailSmtpPort'), + type: 'number', + }, + 'mail.smtp.starttls.enable': { + name: t('attributeMailSmtpStarttlsEnable'), + type: 'boolean', + }, + 'mail.smtp.starttls.required': { + name: t('attributeMailSmtpStarttlsRequired'), + type: 'boolean', + }, + 'mail.smtp.ssl.enable': { + name: t('attributeMailSmtpSslEnable'), + type: 'boolean', + }, + 'mail.smtp.ssl.trust': { + name: t('attributeMailSmtpSslTrust'), + type: 'string', + }, + 'mail.smtp.ssl.protocols': { + name: t('attributeMailSmtpSslProtocols'), + type: 'string', + }, + 'mail.smtp.from': { + name: t('attributeMailSmtpFrom'), + type: 'string', + }, + 'mail.smtp.auth': { + name: t('attributeMailSmtpAuth'), + type: 'boolean', + }, + 'mail.smtp.username': { + name: t('attributeMailSmtpUsername'), + type: 'string', + }, + 'mail.smtp.password': { + name: t('attributeMailSmtpPassword'), + type: 'string', + }, +}), [t]); 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; diff --git a/src/common/theme/components.js b/src/common/theme/components.js new file mode 100644 index 00000000..56a2ac75 --- /dev/null +++ b/src/common/theme/components.js @@ -0,0 +1,40 @@ +export default { + MuiUseMediaQuery: { + defaultProps: { + noSsr: true, + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.palette.background.default, + }), + }, + }, + MuiButton: { + styleOverrides: { + sizeMedium: { + height: '40px', + }, + }, + }, + MuiFormControl: { + defaultProps: { + size: 'small', + }, + }, + MuiSnackbar: { + defaultProps: { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, + }, + }, + MuiTooltip: { + defaultProps: { + enterDelay: 500, + enterNextDelay: 500, + }, + }, +}; diff --git a/src/common/theme/dimensions.js b/src/common/theme/dimensions.js new file mode 100644 index 00000000..4930803a --- /dev/null +++ b/src/common/theme/dimensions.js @@ -0,0 +1,14 @@ +export default { + sidebarWidth: '28%', + sidebarWidthTablet: '52px', + drawerWidthDesktop: '360px', + drawerWidthTablet: '320px', + drawerHeightPhone: '250px', + filterFormWidth: '160px', + eventsDrawerWidth: '320px', + bottomBarHeight: 56, + popupMapOffset: 300, + popupMaxWidth: 288, + popupImageHeight: 144, + cardContentMaxHeight: '40vh', +}; diff --git a/src/common/theme/index.js b/src/common/theme/index.js new file mode 100644 index 00000000..e8ce698b --- /dev/null +++ b/src/common/theme/index.js @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; +import { createTheme } from '@mui/material/styles'; +import palette from './palette'; +import dimensions from './dimensions'; +import components from './components'; + +export default (server, darkMode) => useMemo(() => createTheme({ + palette: palette(server, darkMode), + dimensions, + components, +}), [server, darkMode]); diff --git a/src/common/theme/palette.js b/src/common/theme/palette.js new file mode 100644 index 00000000..f32ed93e --- /dev/null +++ b/src/common/theme/palette.js @@ -0,0 +1,22 @@ +import { grey, green, indigo } from '@mui/material/colors'; + +const validatedColor = (color) => (/^#([0-9A-Fa-f]{3}){1,2}$/.test(color) ? color : null); + +export default (server, darkMode) => ({ + mode: darkMode ? 'dark' : 'light', + background: { + default: darkMode ? grey[900] : grey[50], + }, + primary: { + main: validatedColor(server?.attributes?.colorPrimary) || (darkMode ? indigo[200] : indigo[900]), + }, + secondary: { + main: validatedColor(server?.attributes?.colorSecondary) || (darkMode ? green[200] : green[800]), + }, + neutral: { + main: grey[500], + }, + geometry: { + main: '#3bb2d0', + }, +}); diff --git a/src/common/util/converter.js b/src/common/util/converter.js new file mode 100644 index 00000000..cb21566b --- /dev/null +++ b/src/common/util/converter.js @@ -0,0 +1,107 @@ +const speedConverter = (unit) => { + switch (unit) { + case 'kmh': + return 1.852; + case 'mph': + return 1.15078; + case 'kn': + default: + return 1; + } +}; + +export const speedUnitString = (unit, t) => { + switch (unit) { + case 'kmh': + return t('sharedKmh'); + case 'mph': + return t('sharedMph'); + case 'kn': + default: + return t('sharedKn'); + } +}; + +export const speedFromKnots = (value, unit) => value * speedConverter(unit); + +export const speedToKnots = (value, unit) => value / speedConverter(unit); + +const distanceConverter = (unit) => { + switch (unit) { + case 'mi': + return 0.000621371; + case 'nmi': + return 0.000539957; + case 'km': + default: + return 0.001; + } +}; + +export const distanceUnitString = (unit, t) => { + switch (unit) { + case 'mi': + return t('sharedMi'); + case 'nmi': + return t('sharedNmi'); + case 'km': + default: + return t('sharedKm'); + } +}; + +export const distanceFromMeters = (value, unit) => value * distanceConverter(unit); + +export const distanceToMeters = (value, unit) => value / distanceConverter(unit); + +const altitudeConverter = (unit) => { + switch (unit) { + case 'ft': + return 3.28084; + case 'm': + default: + return 1; + } +}; + +export const altitudeUnitString = (unit, t) => { + switch (unit) { + case 'ft': + return t('sharedFeet'); + case 'm': + default: + return t('sharedMeters'); + } +}; + +export const altitudeFromMeters = (value, unit) => value * altitudeConverter(unit); + +export const altitudeToMeters = (value, unit) => value / altitudeConverter(unit); + +const volumeConverter = (unit) => { + switch (unit) { + case 'impGal': + return 4.546; + case 'usGal': + return 3.785; + case 'ltr': + default: + return 1; + } +}; + +export const volumeUnitString = (unit, t) => { + switch (unit) { + case 'impGal': + return t('sharedGallonAbbreviation'); + case 'usGal': + return t('sharedGallonAbbreviation'); + case 'ltr': + default: + return t('sharedLiterAbbreviation'); + } +}; + +export const volumeFromLiters = (value, unit) => value / volumeConverter(unit); + +export const volumeToLiters = (value, unit) => value * volumeConverter(unit); diff --git a/src/common/util/deviceCategories.js b/src/common/util/deviceCategories.js new file mode 100644 index 00000000..a991e505 --- /dev/null +++ b/src/common/util/deviceCategories.js @@ -0,0 +1,24 @@ +export default [ + 'default', + 'animal', + 'bicycle', + 'boat', + 'bus', + 'car', + 'camper', + 'crane', + 'helicopter', + 'motorcycle', + 'offroad', + 'person', + 'pickup', + 'plane', + 'ship', + 'tractor', + 'train', + 'tram', + 'trolleybus', + 'truck', + 'van', + 'scooter', +]; diff --git a/src/common/util/duration.js b/src/common/util/duration.js new file mode 100644 index 00000000..aae74868 --- /dev/null +++ b/src/common/util/duration.js @@ -0,0 +1,2 @@ +export const snackBarDurationShortMs = 1500; +export const snackBarDurationLongMs = 2750; diff --git a/src/common/util/formatter.js b/src/common/util/formatter.js new file mode 100644 index 00000000..7b7fc96d --- /dev/null +++ b/src/common/util/formatter.js @@ -0,0 +1,143 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +import { + altitudeFromMeters, + altitudeUnitString, + distanceFromMeters, + distanceUnitString, + speedFromKnots, + speedUnitString, + volumeFromLiters, + volumeUnitString, +} from './converter'; +import { prefixString } from './stringUtils'; + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +export const formatBoolean = (value, t) => (value ? t('sharedYes') : t('sharedNo')); + +export const formatNumber = (value, precision = 1) => Number(value.toFixed(precision)); + +export const formatPercentage = (value) => `${value}%`; + +export const formatTemperature = (value) => `${value}°C`; + +export const formatVoltage = (value, t) => `${value} ${t('sharedVoltAbbreviation')}`; + +export const formatConsumption = (value, t) => `${value} ${t('sharedLiterPerHourAbbreviation')}`; + +export const formatTime = (value, format, hours12) => { + if (value) { + const d = dayjs(value); + switch (format) { + case 'date': + return d.format('YYYY-MM-DD'); + case 'time': + return d.format(hours12 ? 'hh:mm:ss A' : 'HH:mm:ss'); + case 'minutes': + return d.format(hours12 ? 'YYYY-MM-DD hh:mm A' : 'YYYY-MM-DD HH:mm'); + default: + return d.format(hours12 ? 'YYYY-MM-DD hh:mm:ss A' : 'YYYY-MM-DD HH:mm:ss'); + } + } + return ''; +}; + +export const formatStatus = (value, t) => t(prefixString('deviceStatus', value)); +export const formatAlarm = (value, t) => (value ? t(prefixString('alarm', value)) : ''); + +export const formatCourse = (value) => { + const courseValues = ['\u2191', '\u2197', '\u2192', '\u2198', '\u2193', '\u2199', '\u2190', '\u2196']; + let normalizedValue = (value + 45 / 2) % 360; + if (normalizedValue < 0) { + normalizedValue += 360; + } + return courseValues[Math.floor(normalizedValue / 45)]; +}; + +export const formatDistance = (value, unit, t) => `${distanceFromMeters(value, unit).toFixed(2)} ${distanceUnitString(unit, t)}`; + +export const formatAltitude = (value, unit, t) => `${altitudeFromMeters(value, unit).toFixed(2)} ${altitudeUnitString(unit, t)}`; + +export const formatSpeed = (value, unit, t) => `${speedFromKnots(value, unit).toFixed(2)} ${speedUnitString(unit, t)}`; + +export const formatVolume = (value, unit, t) => `${volumeFromLiters(value, unit).toFixed(2)} ${volumeUnitString(unit, t)}`; + +export const formatNumericHours = (value, t) => { + const hours = Math.floor(value / 3600000); + const minutes = Math.floor((value % 3600000) / 60000); + return `${hours} ${t('sharedHourAbbreviation')} ${minutes} ${t('sharedMinuteAbbreviation')}`; +}; + +export const formatCoordinate = (key, value, unit) => { + let hemisphere; + let degrees; + let minutes; + let seconds; + + if (key === 'latitude') { + hemisphere = value >= 0 ? 'N' : 'S'; + } else { + hemisphere = value >= 0 ? 'E' : 'W'; + } + + switch (unit) { + case 'ddm': + value = Math.abs(value); + degrees = Math.floor(value); + minutes = (value - degrees) * 60; + return `${degrees}° ${minutes.toFixed(6)}' ${hemisphere}`; + case 'dms': + value = Math.abs(value); + degrees = Math.floor(value); + minutes = Math.floor((value - degrees) * 60); + seconds = Math.round((value - degrees - minutes / 60) * 3600); + return `${degrees}° ${minutes}' ${seconds}" ${hemisphere}`; + default: + return `${value.toFixed(6)}°`; + } +}; + +export const getStatusColor = (status) => { + switch (status) { + case 'online': + return 'success'; + case 'offline': + return 'error'; + case 'unknown': + default: + return 'neutral'; + } +}; + +export const getBatteryStatus = (batteryLevel) => { + if (batteryLevel >= 70) { + return 'success'; + } + if (batteryLevel > 30) { + return 'warning'; + } + return 'error'; +}; + +export const formatNotificationTitle = (t, notification, includeId) => { + let title = t(prefixString('event', notification.type)); + if (notification.type === 'alarm') { + const alarmString = notification.attributes.alarms; + if (alarmString) { + const alarms = alarmString.split(','); + if (alarms.length > 1) { + title += ` (${alarms.length})`; + } else { + title += ` ${formatAlarm(alarms[0], t)}`; + } + } + } + if (includeId) { + title += ` [${notification.id}]`; + } + return title; +}; diff --git a/src/common/util/permissions.js b/src/common/util/permissions.js new file mode 100644 index 00000000..8a63b5a1 --- /dev/null +++ b/src/common/util/permissions.js @@ -0,0 +1,28 @@ +import { useSelector } from 'react-redux'; + +export const useAdministrator = () => useSelector((state) => { + const admin = state.session.user.administrator; + return admin; +}); + +export const useManager = () => useSelector((state) => { + const admin = state.session.user.administrator; + const manager = (state.session.user.userLimit || 0) !== 0; + return admin || manager; +}); + +export const useDeviceReadonly = () => useSelector((state) => { + const admin = state.session.user.administrator; + const serverReadonly = state.session.server.readonly; + const userReadonly = state.session.user.readonly; + const serverDeviceReadonly = state.session.server.deviceReadonly; + const userDeviceReadonly = state.session.user.deviceReadonly; + return !admin && (serverReadonly || userReadonly || serverDeviceReadonly || userDeviceReadonly); +}); + +export const useRestriction = (key) => useSelector((state) => { + const admin = state.session.user.administrator; + const serverValue = state.session.server[key]; + const userValue = state.session.user[key]; + return !admin && (serverValue || userValue); +}); diff --git a/src/common/util/preferences.js b/src/common/util/preferences.js new file mode 100644 index 00000000..229b6f17 --- /dev/null +++ b/src/common/util/preferences.js @@ -0,0 +1,41 @@ +import { useSelector } from 'react-redux'; + +const containsProperty = (object, key) => object.hasOwnProperty(key) && object[key] !== null; + +export const usePreference = (key, defaultValue) => useSelector((state) => { + if (state.session.server.forceSettings) { + if (containsProperty(state.session.server, key)) { + return state.session.server[key]; + } + if (containsProperty(state.session.user, key)) { + return state.session.user[key]; + } + return defaultValue; + } + if (containsProperty(state.session.user, key)) { + return state.session.user[key]; + } + if (containsProperty(state.session.server, key)) { + return state.session.server[key]; + } + return defaultValue; +}); + +export const useAttributePreference = (key, defaultValue) => useSelector((state) => { + if (state.session.server.forceSettings) { + if (containsProperty(state.session.server.attributes, key)) { + return state.session.server.attributes[key]; + } + if (containsProperty(state.session.user.attributes, key)) { + return state.session.user.attributes[key]; + } + return defaultValue; + } + if (containsProperty(state.session.user.attributes, key)) { + return state.session.user.attributes[key]; + } + if (containsProperty(state.session.server.attributes, key)) { + return state.session.server.attributes[key]; + } + return defaultValue; +}); diff --git a/src/common/util/stringUtils.js b/src/common/util/stringUtils.js new file mode 100644 index 00000000..fc997fe0 --- /dev/null +++ b/src/common/util/stringUtils.js @@ -0,0 +1,3 @@ +export const prefixString = (prefix, value) => prefix + value.charAt(0).toUpperCase() + value.slice(1); + +export const unprefixString = (prefix, value) => value.charAt(prefix.length).toLowerCase() + value.slice(prefix.length + 1); diff --git a/src/common/util/useFeatures.js b/src/common/util/useFeatures.js new file mode 100644 index 00000000..30361589 --- /dev/null +++ b/src/common/util/useFeatures.js @@ -0,0 +1,44 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useSelector } from 'react-redux'; + +const get = (server, user, key) => { + if (server && user) { + if (user.administrator) { + return false; + } + if (server.forceSettings) { + return server.attributes[key] || user.attributes[key] || false; + } + return user.attributes[key] || server.attributes[key] || false; + } + return false; +}; + +const featureSelector = createSelector( + (state) => state.session.server, + (state) => state.session.user, + (server, user) => { + const disableSavedCommands = get(server, user, 'ui.disableSavedCommands'); + const disableAttributes = get(server, user, 'ui.disableAttributes'); + const disableVehicleFeatures = get(server, user, 'ui.disableVehicleFeatures'); + const disableDrivers = disableVehicleFeatures || get(server, user, 'ui.disableDrivers'); + const disableMaintenance = disableVehicleFeatures || get(server, user, 'ui.disableMaintenance'); + const disableGroups = get(server, user, 'ui.disableGroups'); + const disableEvents = get(server, user, 'ui.disableEvents'); + const disableComputedAttributes = get(server, user, 'ui.disableComputedAttributes'); + const disableCalendars = get(server, user, 'ui.disableCalendars'); + + return { + disableSavedCommands, + disableAttributes, + disableDrivers, + disableMaintenance, + disableGroups, + disableEvents, + disableComputedAttributes, + disableCalendars, + }; + }, +); + +export default () => useSelector(featureSelector); diff --git a/src/common/util/usePersistedState.js b/src/common/util/usePersistedState.js new file mode 100644 index 00000000..70a652ad --- /dev/null +++ b/src/common/util/usePersistedState.js @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +export const savePersistedState = (key, value) => { + window.localStorage.setItem(key, JSON.stringify(value)); +}; + +export default (key, defaultValue) => { + const [value, setValue] = useState(() => { + const stickyValue = window.localStorage.getItem(key); + return stickyValue ? JSON.parse(stickyValue) : defaultValue; + }); + + useEffect(() => { + if (value !== defaultValue) { + savePersistedState(key, value); + } else { + window.localStorage.removeItem(key); + } + }, [key, value]); + + return [value, setValue]; +}; diff --git a/src/common/util/useQuery.js b/src/common/util/useQuery.js new file mode 100644 index 00000000..f246df7c --- /dev/null +++ b/src/common/util/useQuery.js @@ -0,0 +1,7 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +export default () => { + const { search } = useLocation(); + return useMemo(() => new URLSearchParams(search), [search]); +}; |