diff options
Diffstat (limited to 'modern/src/settings/PreferencesPage.jsx')
-rw-r--r-- | modern/src/settings/PreferencesPage.jsx | 398 |
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; |