aboutsummaryrefslogtreecommitdiff
path: root/modern/src/settings/PreferencesPage.jsx
diff options
context:
space:
mode:
Diffstat (limited to 'modern/src/settings/PreferencesPage.jsx')
-rw-r--r--modern/src/settings/PreferencesPage.jsx398
1 files changed, 398 insertions, 0 deletions
diff --git a/modern/src/settings/PreferencesPage.jsx b/modern/src/settings/PreferencesPage.jsx
new file mode 100644
index 00000000..a05924a9
--- /dev/null
+++ b/modern/src/settings/PreferencesPage.jsx
@@ -0,0 +1,398 @@
+import React, { useState } from 'react';
+import moment from 'moment';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography, Container, FormControl, InputLabel, Select, MenuItem, Checkbox, FormControlLabel, FormGroup, InputAdornment, IconButton, OutlinedInput, Autocomplete, TextField, createFilterOptions, Button,
+} from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import CachedIcon from '@mui/icons-material/Cached';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useTranslation, useTranslationKeys } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import { prefixString, unprefixString } from '../common/util/stringUtils';
+import SelectField from '../common/components/SelectField';
+import useMapStyles from '../map/core/useMapStyles';
+import useMapOverlays from '../map/overlay/useMapOverlays';
+import { useCatch } from '../reactHelper';
+import { sessionActions } from '../store';
+import { useRestriction } from '../common/util/permissions';
+
+const deviceFields = [
+ { id: 'name', name: 'sharedName' },
+ { id: 'uniqueId', name: 'deviceIdentifier' },
+ { id: 'phone', name: 'sharedPhone' },
+ { id: 'model', name: 'deviceModel' },
+ { id: 'contact', name: 'deviceContact' },
+];
+
+const useStyles = makeStyles((theme) => ({
+ container: {
+ marginTop: theme.spacing(2),
+ },
+ buttons: {
+ marginTop: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ display: 'flex',
+ justifyContent: 'space-evenly',
+ '& > *': {
+ flexBasis: '33%',
+ },
+ },
+ details: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ paddingBottom: theme.spacing(3),
+ },
+ tokenActions: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+}));
+
+const PreferencesPage = () => {
+ const classes = useStyles();
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const readonly = useRestriction('readonly');
+
+ const user = useSelector((state) => state.session.user);
+ const [attributes, setAttributes] = useState(user.attributes);
+
+ const versionApp = process.env.REACT_APP_VERSION.slice(0, -2);
+ const versionServer = useSelector((state) => state.session.server.version);
+ const socket = useSelector((state) => state.session.socket);
+
+ const [token, setToken] = useState(null);
+ const [tokenExpiration, setTokenExpiration] = useState(moment().add(1, 'week').locale('en').format(moment.HTML5_FMT.DATE));
+
+ const mapStyles = useMapStyles();
+ const mapOverlays = useMapOverlays();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const filter = createFilterOptions();
+
+ const generateToken = useCatch(async () => {
+ const expiration = moment(tokenExpiration, moment.HTML5_FMT.DATE).toISOString();
+ const response = await fetch('/api/session/token', {
+ method: 'POST',
+ body: new URLSearchParams(`expiration=${expiration}`),
+ });
+ if (response.ok) {
+ setToken(await response.text());
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const alarms = useTranslationKeys((it) => it.startsWith('alarm')).map((it) => ({
+ key: unprefixString('alarm', it),
+ name: t(it),
+ }));
+
+ const handleSave = useCatch(async () => {
+ const response = await fetch(`/api/users/${user.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...user, attributes }),
+ });
+ if (response.ok) {
+ dispatch(sessionActions.updateUser(await response.json()));
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedPreferences']}>
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('userToken')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ label={t('userExpirationTime')}
+ type="date"
+ value={tokenExpiration}
+ onChange={(e) => {
+ setTokenExpiration(e.target.value);
+ setToken(null);
+ }}
+ />
+ <FormControl>
+ <OutlinedInput
+ multiline
+ rows={6}
+ readOnly
+ type="text"
+ value={token || ''}
+ endAdornment={(
+ <InputAdornment position="end">
+ <div className={classes.tokenActions}>
+ <IconButton size="small" edge="end" onClick={generateToken} disabled={!!token}>
+ <CachedIcon fontSize="small" />
+ </IconButton>
+ <IconButton size="small" edge="end" onClick={() => navigator.clipboard.writeText(token)} disabled={!token}>
+ <ContentCopyIcon fontSize="small" />
+ </IconButton>
+ </div>
+ </InputAdornment>
+ )}
+ />
+ </FormControl>
+ </AccordionDetails>
+ </Accordion>
+ {!readonly && (
+ <>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('mapTitle')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <FormControl>
+ <InputLabel>{t('mapActive')}</InputLabel>
+ <Select
+ label={t('mapActive')}
+ value={attributes.activeMapStyles?.split(',') || ['locationIqStreets', 'osm', 'carto']}
+ onChange={(e, child) => {
+ const clicked = mapStyles.find((s) => s.id === child.props.value);
+ if (clicked.available) {
+ setAttributes({ ...attributes, activeMapStyles: e.target.value.join(',') });
+ } else if (clicked.id !== 'custom') {
+ const query = new URLSearchParams({ attribute: clicked.attribute });
+ navigate(`/settings/user/${user.id}?${query.toString()}`);
+ }
+ }}
+ multiple
+ >
+ {mapStyles.map((style) => (
+ <MenuItem key={style.id} value={style.id}>
+ <Typography component="span" color={style.available ? 'textPrimary' : 'error'}>{style.title}</Typography>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('mapOverlay')}</InputLabel>
+ <Select
+ label={t('mapOverlay')}
+ value={attributes.selectedMapOverlay || ''}
+ onChange={(e) => {
+ const clicked = mapOverlays.find((o) => o.id === e.target.value);
+ if (!clicked || clicked.available) {
+ setAttributes({ ...attributes, selectedMapOverlay: e.target.value });
+ } else if (clicked.id !== 'custom') {
+ const query = new URLSearchParams({ attribute: clicked.attribute });
+ navigate(`/settings/user/${user.id}?${query.toString()}`);
+ }
+ }}
+ >
+ <MenuItem value="">{'\u00a0'}</MenuItem>
+ {mapOverlays.map((overlay) => (
+ <MenuItem key={overlay.id} value={overlay.id}>
+ <Typography component="span" color={overlay.available ? 'textPrimary' : 'error'}>{overlay.title}</Typography>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <Autocomplete
+ multiple
+ freeSolo
+ options={Object.keys(positionAttributes)}
+ getOptionLabel={(option) => (positionAttributes.hasOwnProperty(option) ? positionAttributes[option].name : option)}
+ value={attributes.positionItems?.split(',') || ['speed', 'address', 'totalDistance', 'course']}
+ onChange={(_, option) => {
+ setAttributes({ ...attributes, positionItems: option.join(',') });
+ }}
+ filterOptions={(options, params) => {
+ const filtered = filter(options, params);
+ if (params.inputValue && !filtered.includes(params.inputValue)) {
+ filtered.push(params.inputValue);
+ }
+ return filtered;
+ }}
+ renderInput={(params) => (
+ <TextField
+ {...params}
+ label={t('attributePopupInfo')}
+ />
+ )}
+ />
+ <FormControl>
+ <InputLabel>{t('mapLiveRoutes')}</InputLabel>
+ <Select
+ label={t('mapLiveRoutes')}
+ value={attributes.mapLiveRoutes || 'none'}
+ onChange={(e) => setAttributes({ ...attributes, mapLiveRoutes: e.target.value })}
+ >
+ <MenuItem value="none">{t('sharedDisabled')}</MenuItem>
+ <MenuItem value="selected">{t('deviceSelected')}</MenuItem>
+ <MenuItem value="all">{t('notificationAlways')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('mapDirection')}</InputLabel>
+ <Select
+ label={t('mapDirection')}
+ value={attributes.mapDirection || 'selected'}
+ onChange={(e) => setAttributes({ ...attributes, mapDirection: e.target.value })}
+ >
+ <MenuItem value="none">{t('sharedDisabled')}</MenuItem>
+ <MenuItem value="selected">{t('deviceSelected')}</MenuItem>
+ <MenuItem value="all">{t('notificationAlways')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormGroup>
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapGeofences') ? attributes.mapGeofences : true}
+ onChange={(e) => setAttributes({ ...attributes, mapGeofences: e.target.checked })}
+ />
+ )}
+ label={t('attributeShowGeofences')}
+ />
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapFollow') ? attributes.mapFollow : false}
+ onChange={(e) => setAttributes({ ...attributes, mapFollow: e.target.checked })}
+ />
+ )}
+ label={t('deviceFollow')}
+ />
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapCluster') ? attributes.mapCluster : true}
+ onChange={(e) => setAttributes({ ...attributes, mapCluster: e.target.checked })}
+ />
+ )}
+ label={t('mapClustering')}
+ />
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapOnSelect') ? attributes.mapOnSelect : true}
+ onChange={(e) => setAttributes({ ...attributes, mapOnSelect: e.target.checked })}
+ />
+ )}
+ label={t('mapOnSelect')}
+ />
+ </FormGroup>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('deviceTitle')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ emptyValue={null}
+ value={attributes.devicePrimary || 'name'}
+ onChange={(e) => setAttributes({ ...attributes, devicePrimary: e.target.value })}
+ data={deviceFields}
+ titleGetter={(it) => t(it.name)}
+ label={t('devicePrimaryInfo')}
+ />
+ <SelectField
+ emptyValue=""
+ value={attributes.deviceSecondary || ''}
+ onChange={(e) => setAttributes({ ...attributes, deviceSecondary: e.target.value })}
+ data={deviceFields}
+ titleGetter={(it) => t(it.name)}
+ label={t('deviceSecondaryInfo')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedSound')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ multiple
+ value={attributes.soundEvents?.split(',') || []}
+ onChange={(e) => setAttributes({ ...attributes, soundEvents: e.target.value.join(',') })}
+ endpoint="/api/notifications/types"
+ keyGetter={(it) => it.type}
+ titleGetter={(it) => t(prefixString('event', it.type))}
+ label={t('eventsSoundEvents')}
+ />
+ <SelectField
+ multiple
+ value={attributes.soundAlarms?.split(',') || ['sos']}
+ onChange={(e) => setAttributes({ ...attributes, soundAlarms: e.target.value.join(',') })}
+ data={alarms}
+ keyGetter={(it) => it.key}
+ label={t('eventsSoundAlarms')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedInfoTitle')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={versionApp}
+ label={t('settingsAppVersion')}
+ disabled
+ />
+ <TextField
+ value={versionServer || '-'}
+ label={t('settingsServerVersion')}
+ disabled
+ />
+ <TextField
+ value={socket ? t('deviceStatusOnline') : t('deviceStatusOffline')}
+ label={t('settingsConnection')}
+ disabled
+ />
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSave}
+ >
+ {t('sharedSave')}
+ </Button>
+ </div>
+ </>
+ )}
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default PreferencesPage;