aboutsummaryrefslogtreecommitdiff
path: root/src/common
diff options
context:
space:
mode:
authorAnton Tananaev <anton@traccar.org>2024-04-06 09:22:10 -0700
committerAnton Tananaev <anton@traccar.org>2024-04-06 09:22:10 -0700
commitf418231b6b2f5e030a0d2dcc390c314602b1f740 (patch)
tree10326adf3792bc2697e06bb5f2b8ef2a8f7e55fe /src/common
parentb392a4af78e01c8e0f50aad5468e9583675b24be (diff)
downloadtrackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.tar.gz
trackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.tar.bz2
trackermap-web-f418231b6b2f5e030a0d2dcc390c314602b1f740.zip
Move modern to the top
Diffstat (limited to 'src/common')
-rw-r--r--src/common/attributes/useCommandAttributes.js213
-rw-r--r--src/common/attributes/useCommonDeviceAttributes.js21
-rw-r--r--src/common/attributes/useCommonUserAttributes.js136
-rw-r--r--src/common/attributes/useDeviceAttributes.js33
-rw-r--r--src/common/attributes/useGeofenceAttributes.js18
-rw-r--r--src/common/attributes/useGroupAttributes.js12
-rw-r--r--src/common/attributes/usePositionAttributes.js380
-rw-r--r--src/common/attributes/useServerAttributes.js62
-rw-r--r--src/common/attributes/useUserAttributes.js60
-rw-r--r--src/common/components/AddressValue.jsx37
-rw-r--r--src/common/components/BottomMenu.jsx135
-rw-r--r--src/common/components/DriverValue.js9
-rw-r--r--src/common/components/ErrorHandler.jsx27
-rw-r--r--src/common/components/GeofencesValue.js9
-rw-r--r--src/common/components/LinkField.jsx93
-rw-r--r--src/common/components/LocalizationProvider.jsx187
-rw-r--r--src/common/components/NativeInterface.js72
-rw-r--r--src/common/components/NavBar.jsx25
-rw-r--r--src/common/components/PageLayout.jsx118
-rw-r--r--src/common/components/PositionValue.jsx133
-rw-r--r--src/common/components/RemoveDialog.jsx54
-rw-r--r--src/common/components/SelectField.jsx77
-rw-r--r--src/common/components/SideNav.jsx33
-rw-r--r--src/common/components/SplitButton.jsx48
-rw-r--r--src/common/components/StatusCard.jsx288
-rw-r--r--src/common/components/TableShimmer.jsx17
-rw-r--r--src/common/theme/components.js40
-rw-r--r--src/common/theme/dimensions.js14
-rw-r--r--src/common/theme/index.js11
-rw-r--r--src/common/theme/palette.js22
-rw-r--r--src/common/util/converter.js107
-rw-r--r--src/common/util/deviceCategories.js24
-rw-r--r--src/common/util/duration.js2
-rw-r--r--src/common/util/formatter.js143
-rw-r--r--src/common/util/permissions.js28
-rw-r--r--src/common/util/preferences.js41
-rw-r--r--src/common/util/stringUtils.js3
-rw-r--r--src/common/util/useFeatures.js44
-rw-r--r--src/common/util/usePersistedState.js22
-rw-r--r--src/common/util/useQuery.js7
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)}
+ &nbsp;&nbsp;
+ {admin && <Link component={RouterLink} underline="none" to={`/settings/accumulators/${position.deviceId}`}>&#9881;</Link>}
+ </>
+ );
+ case 'address':
+ return <AddressValue latitude={position.latitude} longitude={position.longitude} originalAddress={value} />;
+ case 'network':
+ if (value) {
+ return <Link component={RouterLink} underline="none" to={`/network/${position.id}`}>{t('sharedInfoTitle')}</Link>;
+ }
+ return '';
+ case 'geofenceIds':
+ if (value) {
+ return <GeofencesValue geofenceIds={value} />;
+ }
+ return '';
+ case 'driverUniqueId':
+ if (value) {
+ return <DriverValue driverUniqueId={value} />;
+ }
+ return '';
+ default:
+ return formatValue(value);
+ }
+};
+
+export default PositionValue;
diff --git a/src/common/components/RemoveDialog.jsx b/src/common/components/RemoveDialog.jsx
new file mode 100644
index 00000000..0f4254a8
--- /dev/null
+++ b/src/common/components/RemoveDialog.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import Button from '@mui/material/Button';
+import { Snackbar } from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import { useTranslation } from './LocalizationProvider';
+import { useCatch } from '../../reactHelper';
+import { snackBarDurationLongMs } from '../util/duration';
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ [theme.breakpoints.down('md')]: {
+ bottom: `calc(${theme.dimensions.bottomBarHeight}px + ${theme.spacing(1)})`,
+ },
+ },
+ button: {
+ height: 'auto',
+ marginTop: 0,
+ marginBottom: 0,
+ color: theme.palette.error.main,
+ },
+}));
+
+const RemoveDialog = ({
+ open, endpoint, itemId, onResult,
+}) => {
+ const classes = useStyles();
+ const t = useTranslation();
+
+ const handleRemove = useCatch(async () => {
+ const response = await fetch(`/api/${endpoint}/${itemId}`, { method: 'DELETE' });
+ if (response.ok) {
+ onResult(true);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <Snackbar
+ className={classes.root}
+ open={open}
+ autoHideDuration={snackBarDurationLongMs}
+ onClose={() => onResult(false)}
+ message={t('sharedRemoveConfirm')}
+ action={(
+ <Button size="small" className={classes.button} onClick={handleRemove}>
+ {t('sharedRemove')}
+ </Button>
+ )}
+ />
+ );
+};
+
+export default RemoveDialog;
diff --git a/src/common/components/SelectField.jsx b/src/common/components/SelectField.jsx
new file mode 100644
index 00000000..db8c30b0
--- /dev/null
+++ b/src/common/components/SelectField.jsx
@@ -0,0 +1,77 @@
+import {
+ FormControl, InputLabel, MenuItem, Select, Autocomplete, TextField,
+} from '@mui/material';
+import React, { useState } from 'react';
+import { useEffectAsync } from '../../reactHelper';
+
+const SelectField = ({
+ label,
+ fullWidth,
+ multiple,
+ value = null,
+ emptyValue = null,
+ emptyTitle = '',
+ onChange,
+ endpoint,
+ data,
+ keyGetter = (item) => item.id,
+ titleGetter = (item) => item.name,
+}) => {
+ const [items, setItems] = useState(data);
+
+ const getOptionLabel = (option) => {
+ if (typeof option !== 'object') {
+ option = items.find((obj) => keyGetter(obj) === option);
+ }
+ return option ? titleGetter(option) : emptyTitle;
+ };
+
+ useEffectAsync(async () => {
+ if (endpoint) {
+ const response = await fetch(endpoint);
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ }, []);
+
+ if (items) {
+ return (
+ <FormControl fullWidth={fullWidth}>
+ {multiple ? (
+ <>
+ <InputLabel>{label}</InputLabel>
+ <Select
+ label={label}
+ multiple
+ value={value}
+ onChange={onChange}
+ >
+ {items.map((item) => (
+ <MenuItem key={keyGetter(item)} value={keyGetter(item)}>{titleGetter(item)}</MenuItem>
+ ))}
+ </Select>
+ </>
+ ) : (
+ <Autocomplete
+ size="small"
+ options={items}
+ getOptionLabel={getOptionLabel}
+ renderOption={(props, option) => (
+ <MenuItem {...props} key={keyGetter(option)} value={keyGetter(option)}>{titleGetter(option)}</MenuItem>
+ )}
+ isOptionEqualToValue={(option, value) => keyGetter(option) === value}
+ value={value}
+ onChange={(_, value) => onChange({ target: { value: value ? keyGetter(value) : emptyValue } })}
+ renderInput={(params) => <TextField {...params} label={label} />}
+ />
+ )}
+ </FormControl>
+ );
+ }
+ return null;
+};
+
+export default SelectField;
diff --git a/src/common/components/SideNav.jsx b/src/common/components/SideNav.jsx
new file mode 100644
index 00000000..97968bd1
--- /dev/null
+++ b/src/common/components/SideNav.jsx
@@ -0,0 +1,33 @@
+import React, { Fragment } from 'react';
+import {
+ List, ListItemText, ListItemIcon, Divider, ListSubheader, ListItemButton,
+} from '@mui/material';
+import { Link, useLocation } from 'react-router-dom';
+
+const SideNav = ({ routes }) => {
+ const location = useLocation();
+
+ return (
+ <List disablePadding style={{ paddingTop: '16px' }}>
+ {routes.map((route) => (route.subheader ? (
+ <Fragment key={route.subheader}>
+ <Divider />
+ <ListSubheader>{route.subheader}</ListSubheader>
+ </Fragment>
+ ) : (
+ <ListItemButton
+ disableRipple
+ component={Link}
+ key={route.href}
+ to={route.href}
+ selected={location.pathname.match(route.match || route.href) !== null}
+ >
+ <ListItemIcon>{route.icon}</ListItemIcon>
+ <ListItemText primary={route.name} />
+ </ListItemButton>
+ )))}
+ </List>
+ );
+};
+
+export default SideNav;
diff --git a/src/common/components/SplitButton.jsx b/src/common/components/SplitButton.jsx
new file mode 100644
index 00000000..84876f15
--- /dev/null
+++ b/src/common/components/SplitButton.jsx
@@ -0,0 +1,48 @@
+import React, { useRef, useState } from 'react';
+import {
+ Button, ButtonGroup, Menu, MenuItem, Typography,
+} from '@mui/material';
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+
+const SplitButton = ({
+ fullWidth, variant, color, disabled, onClick, options, selected, setSelected,
+}) => {
+ const anchorRef = useRef();
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
+
+ return (
+ <>
+ <ButtonGroup fullWidth={fullWidth} variant={variant} color={color} ref={anchorRef}>
+ <Button disabled={disabled} onClick={() => onClick(selected)}>
+ <Typography variant="button" noWrap>{options[selected]}</Typography>
+ </Button>
+ <Button fullWidth={false} size="small" onClick={() => setMenuAnchorEl(anchorRef.current)}>
+ <ArrowDropDownIcon />
+ </Button>
+ </ButtonGroup>
+ <Menu
+ open={!!menuAnchorEl}
+ anchorEl={menuAnchorEl}
+ onClose={() => setMenuAnchorEl(null)}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'right',
+ }}
+ >
+ {Object.entries(options).map(([key, value]) => (
+ <MenuItem
+ key={key}
+ onClick={() => {
+ setSelected(key);
+ setMenuAnchorEl(null);
+ }}
+ >
+ {value}
+ </MenuItem>
+ ))}
+ </Menu>
+ </>
+ );
+};
+
+export default SplitButton;
diff --git a/src/common/components/StatusCard.jsx b/src/common/components/StatusCard.jsx
new file mode 100644
index 00000000..a63d0f80
--- /dev/null
+++ b/src/common/components/StatusCard.jsx
@@ -0,0 +1,288 @@
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import Draggable from 'react-draggable';
+import {
+ Card,
+ CardContent,
+ Typography,
+ CardActions,
+ IconButton,
+ Table,
+ TableBody,
+ TableRow,
+ TableCell,
+ Menu,
+ MenuItem,
+ CardMedia,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import CloseIcon from '@mui/icons-material/Close';
+import ReplayIcon from '@mui/icons-material/Replay';
+import PublishIcon from '@mui/icons-material/Publish';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import PendingIcon from '@mui/icons-material/Pending';
+
+import { useTranslation } from './LocalizationProvider';
+import RemoveDialog from './RemoveDialog';
+import PositionValue from './PositionValue';
+import { useDeviceReadonly } from '../util/permissions';
+import usePositionAttributes from '../attributes/usePositionAttributes';
+import { devicesActions } from '../../store';
+import { useCatch, useCatchCallback } from '../../reactHelper';
+import { useAttributePreference } from '../util/preferences';
+
+const useStyles = makeStyles((theme) => ({
+ card: {
+ pointerEvents: 'auto',
+ width: theme.dimensions.popupMaxWidth,
+ },
+ media: {
+ height: theme.dimensions.popupImageHeight,
+ display: 'flex',
+ justifyContent: 'flex-end',
+ alignItems: 'flex-start',
+ },
+ mediaButton: {
+ color: theme.palette.primary.contrastText,
+ mixBlendMode: 'difference',
+ },
+ header: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: theme.spacing(1, 1, 0, 2),
+ },
+ content: {
+ paddingTop: theme.spacing(1),
+ paddingBottom: theme.spacing(1),
+ maxHeight: theme.dimensions.cardContentMaxHeight,
+ overflow: 'auto',
+ },
+ delete: {
+ color: theme.palette.error.main,
+ },
+ icon: {
+ width: '25px',
+ height: '25px',
+ filter: 'brightness(0) invert(1)',
+ },
+ table: {
+ '& .MuiTableCell-sizeSmall': {
+ paddingLeft: 0,
+ paddingRight: 0,
+ },
+ },
+ cell: {
+ borderBottom: 'none',
+ },
+ actions: {
+ justifyContent: 'space-between',
+ },
+ root: ({ desktopPadding }) => ({
+ pointerEvents: 'none',
+ position: 'fixed',
+ zIndex: 5,
+ left: '50%',
+ [theme.breakpoints.up('md')]: {
+ left: `calc(50% + ${desktopPadding} / 2)`,
+ bottom: theme.spacing(3),
+ },
+ [theme.breakpoints.down('md')]: {
+ left: '50%',
+ bottom: `calc(${theme.spacing(3)} + ${theme.dimensions.bottomBarHeight}px)`,
+ },
+ transform: 'translateX(-50%)',
+ }),
+}));
+
+const StatusRow = ({ name, content }) => {
+ const classes = useStyles();
+
+ return (
+ <TableRow>
+ <TableCell className={classes.cell}>
+ <Typography variant="body2">{name}</Typography>
+ </TableCell>
+ <TableCell className={classes.cell}>
+ <Typography variant="body2" color="textSecondary">{content}</Typography>
+ </TableCell>
+ </TableRow>
+ );
+};
+
+const StatusCard = ({ deviceId, position, onClose, disableActions, desktopPadding = 0 }) => {
+ const classes = useStyles({ desktopPadding });
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const deviceReadonly = useDeviceReadonly();
+
+ const shareDisabled = useSelector((state) => state.session.server.attributes.disableShare);
+ const user = useSelector((state) => state.session.user);
+ const device = useSelector((state) => state.devices.items[deviceId]);
+
+ const deviceImage = device?.attributes?.deviceImage;
+
+ const positionAttributes = usePositionAttributes(t);
+ const positionItems = useAttributePreference('positionItems', 'speed,address,totalDistance,course');
+
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const [removing, setRemoving] = useState(false);
+
+ const handleRemove = useCatch(async (removed) => {
+ if (removed) {
+ const response = await fetch('/api/devices');
+ if (response.ok) {
+ dispatch(devicesActions.refresh(await response.json()));
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ setRemoving(false);
+ });
+
+ const handleGeofence = useCatchCallback(async () => {
+ const newItem = {
+ name: t('sharedGeofence'),
+ area: `CIRCLE (${position.latitude} ${position.longitude}, 50)`,
+ };
+ const response = await fetch('/api/geofences', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(newItem),
+ });
+ if (response.ok) {
+ const item = await response.json();
+ const permissionResponse = await fetch('/api/permissions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ deviceId: position.deviceId, geofenceId: item.id }),
+ });
+ if (!permissionResponse.ok) {
+ throw Error(await permissionResponse.text());
+ }
+ navigate(`/settings/geofence/${item.id}`);
+ } else {
+ throw Error(await response.text());
+ }
+ }, [navigate, position]);
+
+ return (
+ <>
+ <div className={classes.root}>
+ {device && (
+ <Draggable
+ handle={`.${classes.media}, .${classes.header}`}
+ >
+ <Card elevation={3} className={classes.card}>
+ {deviceImage ? (
+ <CardMedia
+ className={classes.media}
+ image={`/api/media/${device.uniqueId}/${deviceImage}`}
+ >
+ <IconButton
+ size="small"
+ onClick={onClose}
+ onTouchStart={onClose}
+ >
+ <CloseIcon fontSize="small" className={classes.mediaButton} />
+ </IconButton>
+ </CardMedia>
+ ) : (
+ <div className={classes.header}>
+ <Typography variant="body2" color="textSecondary">
+ {device.name}
+ </Typography>
+ <IconButton
+ size="small"
+ onClick={onClose}
+ onTouchStart={onClose}
+ >
+ <CloseIcon fontSize="small" />
+ </IconButton>
+ </div>
+ )}
+ {position && (
+ <CardContent className={classes.content}>
+ <Table size="small" classes={{ root: classes.table }}>
+ <TableBody>
+ {positionItems.split(',').filter((key) => position.hasOwnProperty(key) || position.attributes.hasOwnProperty(key)).map((key) => (
+ <StatusRow
+ key={key}
+ name={positionAttributes[key]?.name || key}
+ content={(
+ <PositionValue
+ position={position}
+ property={position.hasOwnProperty(key) ? key : null}
+ attribute={position.hasOwnProperty(key) ? null : key}
+ />
+ )}
+ />
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ )}
+ <CardActions classes={{ root: classes.actions }} disableSpacing>
+ <IconButton
+ color="secondary"
+ onClick={(e) => setAnchorEl(e.currentTarget)}
+ disabled={!position}
+ >
+ <PendingIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => navigate('/replay')}
+ disabled={disableActions || !position}
+ >
+ <ReplayIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => navigate(`/settings/device/${deviceId}/command`)}
+ disabled={disableActions}
+ >
+ <PublishIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => navigate(`/settings/device/${deviceId}`)}
+ disabled={disableActions || deviceReadonly}
+ >
+ <EditIcon />
+ </IconButton>
+ <IconButton
+ onClick={() => setRemoving(true)}
+ disabled={disableActions || deviceReadonly}
+ className={classes.delete}
+ >
+ <DeleteIcon />
+ </IconButton>
+ </CardActions>
+ </Card>
+ </Draggable>
+ )}
+ </div>
+ {position && (
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
+ <MenuItem onClick={() => navigate(`/position/${position.id}`)}><Typography color="secondary">{t('sharedShowDetails')}</Typography></MenuItem>
+ <MenuItem onClick={handleGeofence}>{t('sharedCreateGeofence')}</MenuItem>
+ <MenuItem component="a" target="_blank" href={`https://www.google.com/maps/search/?api=1&query=${position.latitude}%2C${position.longitude}`}>{t('linkGoogleMaps')}</MenuItem>
+ <MenuItem component="a" target="_blank" href={`http://maps.apple.com/?ll=${position.latitude},${position.longitude}`}>{t('linkAppleMaps')}</MenuItem>
+ <MenuItem component="a" target="_blank" href={`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${position.latitude}%2C${position.longitude}&heading=${position.course}`}>{t('linkStreetView')}</MenuItem>
+ {!shareDisabled && !user.temporary && <MenuItem onClick={() => navigate(`/settings/device/${deviceId}/share`)}>{t('deviceShare')}</MenuItem>}
+ </Menu>
+ )}
+ <RemoveDialog
+ open={removing}
+ endpoint="devices"
+ itemId={deviceId}
+ onResult={(removed) => handleRemove(removed)}
+ />
+ </>
+ );
+};
+
+export default StatusCard;
diff --git a/src/common/components/TableShimmer.jsx b/src/common/components/TableShimmer.jsx
new file mode 100644
index 00000000..08a984a4
--- /dev/null
+++ b/src/common/components/TableShimmer.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Skeleton, TableCell, TableRow } from '@mui/material';
+
+const TableShimmer = ({ columns, startAction, endAction }) => [...Array(3)].map((_, i) => (
+ <TableRow key={-i}>
+ {[...Array(columns)].map((_, j) => {
+ const action = (startAction && j === 0) || (endAction && j === columns - 1);
+ return (
+ <TableCell key={-j} padding={action ? 'none' : 'normal'}>
+ {!action && <Skeleton />}
+ </TableCell>
+ );
+ })}
+ </TableRow>
+));
+
+export default TableShimmer;
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]);
+};