mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-07 16:29:51 +03:00
Multi-language/I18n support #22
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { FC, createRef, createContext, useContext, RefObject } from 'react';
|
||||
import { FC, createRef, createContext, useContext, useEffect, useState, RefObject } from 'react';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
|
||||
import { IconButton } from '@mui/material';
|
||||
@@ -9,6 +9,13 @@ import { FeaturesLoader } from './contexts/features';
|
||||
import CustomTheme from './CustomTheme';
|
||||
import AppRouting from './AppRouting';
|
||||
|
||||
import { localStorageDetector } from 'typesafe-i18n/detectors';
|
||||
import TypesafeI18n from './i18n/i18n-react';
|
||||
import { detectLocale } from './i18n/i18n-util';
|
||||
import { loadLocaleAsync } from './i18n/i18n-util.async';
|
||||
|
||||
const detectedLocale = detectLocale(localStorageDetector);
|
||||
|
||||
const App: FC = () => {
|
||||
const notistackRef: RefObject<any> = createRef();
|
||||
|
||||
@@ -20,24 +27,34 @@ const App: FC = () => {
|
||||
|
||||
const colorMode = useContext(ColorModeContext);
|
||||
|
||||
const [wasLoaded, setWasLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
|
||||
}, []);
|
||||
|
||||
if (!wasLoaded) return null;
|
||||
|
||||
return (
|
||||
<ColorModeContext.Provider value={colorMode}>
|
||||
<CustomTheme>
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
ref={notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={onClickDismiss(key)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
<FeaturesLoader>
|
||||
<AppRouting />
|
||||
</FeaturesLoader>
|
||||
</SnackbarProvider>
|
||||
</CustomTheme>
|
||||
<TypesafeI18n locale={detectedLocale}>
|
||||
<CustomTheme>
|
||||
<SnackbarProvider
|
||||
maxSnack={3}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
ref={notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={onClickDismiss(key)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
<FeaturesLoader>
|
||||
<AppRouting />
|
||||
</FeaturesLoader>
|
||||
</SnackbarProvider>
|
||||
</CustomTheme>
|
||||
</TypesafeI18n>
|
||||
</ColorModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { FC, useContext, useEffect } from 'react';
|
||||
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useSnackbar, VariantType } from 'notistack';
|
||||
|
||||
import { useI18nContext } from './i18n/i18n-react';
|
||||
|
||||
import { Authentication, AuthenticationContext } from './contexts/authentication';
|
||||
import { FeaturesContext } from './contexts/features';
|
||||
import { RequireAuthenticated, RequireUnauthenticated } from './components';
|
||||
@@ -41,13 +43,14 @@ export const RemoveTrailingSlashes = () => {
|
||||
|
||||
const AppRouting: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<Authentication>
|
||||
<RemoveTrailingSlashes />
|
||||
<Routes>
|
||||
<Route path="/unauthorized" element={<RootRedirect message="Please sign in to continue" signOut />} />
|
||||
<Route path="/fileUpdated" element={<RootRedirect message="Upload successful" variant="success" />} />
|
||||
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} />
|
||||
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} variant="success" />} />
|
||||
{features.security && (
|
||||
<Route
|
||||
path="/"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FC, useContext, useState } from 'react';
|
||||
import { FC, useContext, useState, ChangeEventHandler } from 'react';
|
||||
import { ValidateFieldsError } from 'async-validator';
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import { Box, Fab, Paper, Typography } from '@mui/material';
|
||||
import { Box, Fab, Paper, Typography, MenuItem } from '@mui/material';
|
||||
import ForwardIcon from '@mui/icons-material/Forward';
|
||||
|
||||
import * as AuthenticationApi from './api/authentication';
|
||||
@@ -16,6 +16,11 @@ import { SignInRequest } from './types';
|
||||
import { ValidatedTextField } from './components';
|
||||
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
|
||||
|
||||
import { I18nContext } from './i18n/i18n-react';
|
||||
import type { Locales } from './i18n/i18n-types';
|
||||
import { locales } from './i18n/i18n-util';
|
||||
import { loadLocaleAsync } from './i18n/i18n-util.async';
|
||||
|
||||
const SignIn: FC = () => {
|
||||
const authenticationContext = useContext(AuthenticationContext);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -31,6 +36,9 @@ const SignIn: FC = () => {
|
||||
|
||||
const validateAndSignIn = async () => {
|
||||
setProcessing(true);
|
||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||
required: '%s ' + LL.IS_REQUIRED()
|
||||
});
|
||||
try {
|
||||
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||
signIn();
|
||||
@@ -47,7 +55,7 @@ const SignIn: FC = () => {
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
enqueueSnackbar('Invalid login details', { variant: 'warning' });
|
||||
enqueueSnackbar(LL.INVALID_LOGIN(), { variant: 'warning' });
|
||||
}
|
||||
} else {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Unexpected error, please try again'), { variant: 'error' });
|
||||
@@ -58,6 +66,15 @@ const SignIn: FC = () => {
|
||||
|
||||
const submitOnEnter = onEnterCallback(signIn);
|
||||
|
||||
const { locale, LL, setLocale } = useContext(I18nContext);
|
||||
|
||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ target }) => {
|
||||
const loc = target.value as Locales;
|
||||
localStorage.setItem('lang', loc);
|
||||
await loadLocaleAsync(loc);
|
||||
setLocale(loc);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -81,11 +98,33 @@ const SignIn: FC = () => {
|
||||
})}
|
||||
>
|
||||
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
'& .MuiTextField-root': { m: 2, width: '15ch' }
|
||||
}}
|
||||
>
|
||||
<ValidatedTextField
|
||||
name="locale"
|
||||
label={LL.LANGUAGE()}
|
||||
variant="outlined"
|
||||
value={locale || ''}
|
||||
onChange={onLocaleSelected}
|
||||
margin="normal"
|
||||
size="small"
|
||||
select
|
||||
>
|
||||
{locales.map((loc) => (
|
||||
<MenuItem key={loc} value={loc}>
|
||||
{loc}
|
||||
</MenuItem>
|
||||
))}
|
||||
</ValidatedTextField>
|
||||
</Box>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={processing}
|
||||
name="username"
|
||||
label="Username"
|
||||
label={LL.USERNAME()}
|
||||
value={signInRequest.username}
|
||||
onChange={updateLoginRequestValue}
|
||||
margin="normal"
|
||||
@@ -97,7 +136,7 @@ const SignIn: FC = () => {
|
||||
disabled={processing}
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
label={LL.PASSWORD()}
|
||||
value={signInRequest.password}
|
||||
onChange={updateLoginRequestValue}
|
||||
onKeyDown={submitOnEnter}
|
||||
@@ -107,7 +146,7 @@ const SignIn: FC = () => {
|
||||
/>
|
||||
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
|
||||
<ForwardIcon sx={{ mr: 1 }} />
|
||||
Sign In
|
||||
{LL.SIGN_IN()}
|
||||
</Fab>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -15,9 +15,12 @@ import ProjectMenu from '../../project/ProjectMenu';
|
||||
import LayoutMenuItem from './LayoutMenuItem';
|
||||
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||
|
||||
import { useI18nContext } from '../../i18n/i18n-react';
|
||||
|
||||
const LayoutMenu: FC = () => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,11 +31,11 @@ const LayoutMenu: FC = () => {
|
||||
</List>
|
||||
)}
|
||||
<List disablePadding component="nav">
|
||||
<LayoutMenuItem icon={SettingsEthernetIcon} label="Network Connection" to="/network" />
|
||||
<LayoutMenuItem icon={SettingsEthernetIcon} label={LL.NETWORK_CONNECTION()} to="/network" />
|
||||
<LayoutMenuItem icon={SettingsInputAntennaIcon} label="Access Point" to="/ap" />
|
||||
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />}
|
||||
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label={LL.NETWORK_TIME()} to="/ntp" />}
|
||||
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
|
||||
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
|
||||
<LayoutMenuItem icon={LockIcon} label={LL.SECURITY()} to="/security" disabled={!authenticatedContext.me.admin} />
|
||||
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
|
||||
</List>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { FC, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useI18nContext } from '../../i18n/i18n-react';
|
||||
|
||||
import * as AuthenticationApi from '../../api/authentication';
|
||||
import { ACCESS_TOKEN } from '../../api/endpoints';
|
||||
import { RequiredChildrenProps } from '../../utils';
|
||||
@@ -12,6 +14,8 @@ import { AuthenticationContext } from './context';
|
||||
|
||||
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
const { features } = useContext(FeaturesContext);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
@@ -23,7 +27,7 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
||||
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
||||
setMe(decodedMe);
|
||||
enqueueSnackbar(`Logged in as ${decodedMe.username}`, { variant: 'success' });
|
||||
enqueueSnackbar(LL.LOGGED_IN({ name: decodedMe.username }), { variant: 'success' });
|
||||
} catch (error: unknown) {
|
||||
setMe(undefined);
|
||||
throw new Error('Failed to parse JWT');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { ValidateFieldsError } from 'async-validator';
|
||||
|
||||
import { Button, Checkbox, MenuItem, Grid, Typography } from '@mui/material';
|
||||
import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment } from '@mui/material';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
|
||||
import { MQTT_SETTINGS_VALIDATOR, validate } from '../../validators';
|
||||
@@ -129,7 +129,10 @@ const MqttSettingsForm: FC = () => {
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="keep_alive"
|
||||
label="Keep Alive (seconds)"
|
||||
label="Keep Alive"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.keep_alive)}
|
||||
@@ -149,7 +152,7 @@ const MqttSettingsForm: FC = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={0}>0 (default)</MenuItem>
|
||||
<MenuItem value={0}>0</MenuItem>
|
||||
<MenuItem value={1}>1</MenuItem>
|
||||
<MenuItem value={2}>2</MenuItem>
|
||||
</ValidatedTextField>
|
||||
@@ -227,7 +230,7 @@ const MqttSettingsForm: FC = () => {
|
||||
</Grid>
|
||||
)}
|
||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
||||
Publish Intervals (in seconds, 0=automatic)
|
||||
Publish Intervals (0=auto)
|
||||
</Typography>
|
||||
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
|
||||
<Grid item xs={4}>
|
||||
@@ -235,6 +238,9 @@ const MqttSettingsForm: FC = () => {
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_boiler"
|
||||
label="Boilers and Heat Pumps"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_boiler)}
|
||||
@@ -248,6 +254,9 @@ const MqttSettingsForm: FC = () => {
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_thermostat"
|
||||
label="Thermostats"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_thermostat)}
|
||||
@@ -261,6 +270,9 @@ const MqttSettingsForm: FC = () => {
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_solar"
|
||||
label="Solar Modules"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_solar)}
|
||||
@@ -274,6 +286,9 @@ const MqttSettingsForm: FC = () => {
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_mixer"
|
||||
label="Mixer Modules"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_mixer)}
|
||||
@@ -287,6 +302,9 @@ const MqttSettingsForm: FC = () => {
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_sensor"
|
||||
label="Temperature Sensors"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.publish_time_sensor)}
|
||||
@@ -299,6 +317,9 @@ const MqttSettingsForm: FC = () => {
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="publish_time_other"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
label="Default"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
|
||||
@@ -11,12 +11,16 @@ import NetworkStatusForm from './NetworkStatusForm';
|
||||
import WiFiNetworkScanner from './WiFiNetworkScanner';
|
||||
import NetworkSettingsForm from './NetworkSettingsForm';
|
||||
|
||||
import { useI18nContext } from '../../i18n/i18n-react';
|
||||
|
||||
const NetworkConnection: FC = () => {
|
||||
useLayoutTitle('Network Connection');
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.NETWORK_CONNECTION());
|
||||
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
const navigate = useNavigate();
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
ListItemAvatar,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Typography
|
||||
Typography,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
@@ -135,7 +136,10 @@ const WiFiSettingsForm: FC = () => {
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="tx_power"
|
||||
label="WiFi Tx Power (dBm)"
|
||||
label="WiFi Tx Power"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">dBm</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={numberValue(data.tx_power)}
|
||||
|
||||
@@ -9,8 +9,11 @@ import { AuthenticatedContext } from '../../contexts/authentication';
|
||||
import NTPStatusForm from './NTPStatusForm';
|
||||
import NTPSettingsForm from './NTPSettingsForm';
|
||||
|
||||
import { useI18nContext } from '../../i18n/i18n-react';
|
||||
|
||||
const NetworkTime: FC = () => {
|
||||
useLayoutTitle('Network Time');
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.NETWORK_TIME());
|
||||
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
@@ -8,8 +8,11 @@ import { RouterTabs, useRouterTab, useLayoutTitle } from '../../components';
|
||||
import SecuritySettingsForm from './SecuritySettingsForm';
|
||||
import ManageUsersForm from './ManageUsersForm';
|
||||
|
||||
import { useI18nContext } from '../../i18n/i18n-react';
|
||||
|
||||
const Security: FC = () => {
|
||||
useLayoutTitle('Security');
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.SECURITY());
|
||||
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ const GeneralFileUpload: FC<UploadFileProps> = ({ uploadGeneralFile }) => {
|
||||
{!uploading && (
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body2">
|
||||
Upload a new firmware (.bin) file, settings or customizations (.json) file below.
|
||||
Upload a new firmware (.bin) file, settings or customizations (.json) file below
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -86,7 +86,7 @@ const GeneralFileUpload: FC<UploadFileProps> = ({ uploadGeneralFile }) => {
|
||||
<Box color="warning.main">
|
||||
<Typography mb={1} variant="body2">
|
||||
Download the application settings. Be careful when sharing your settings as this file contains passwords
|
||||
and other sensitive system information.
|
||||
and other sensitive system information
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button startIcon={<DownloadIcon />} variant="outlined" color="primary" onClick={() => downloadSettings()}>
|
||||
@@ -95,7 +95,7 @@ const GeneralFileUpload: FC<UploadFileProps> = ({ uploadGeneralFile }) => {
|
||||
|
||||
<Box color="warning.main">
|
||||
<Typography mt={2} mb={1} variant="body2">
|
||||
Download the entity customizations.
|
||||
Download the entity customizations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
|
||||
49
interface/src/i18n/de/index.ts
Normal file
49
interface/src/i18n/de/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Translation } from '../i18n-types';
|
||||
|
||||
const de: Translation = {
|
||||
LANGUAGE: 'Sprache',
|
||||
IS_REQUIRED: 'ist nötig',
|
||||
SIGN_IN: 'Einloggen',
|
||||
USERNAME: 'Nutzername',
|
||||
PASSWORD: 'Passwort',
|
||||
DASHBOARD: 'Armaturenbrett',
|
||||
SETTINGS: 'Einstellungen',
|
||||
HELP: 'Hilfe',
|
||||
LOGGED_IN: 'Eingeloggt als {name}',
|
||||
PLEASE_SIGNIN: 'Bitte einloggen, um fortzufahren',
|
||||
UPLOAD_SUCCESSFUL: 'Hochladen erfolgreich',
|
||||
INVALID_LOGIN: 'Ungültige Login Daten',
|
||||
NETWORK_CONNECTION: 'Netzwerkverbindung',
|
||||
SECURITY: 'Sicherheit',
|
||||
NETWORK_TIME: 'Netzwerkzeit',
|
||||
ONOFF_CAP: 'AN/AUS',
|
||||
ONOFF: 'an/aus',
|
||||
TYPE: 'Typ',
|
||||
DESCRIPTION: 'Bezeichnung',
|
||||
ENTITIES: 'Entitäten',
|
||||
REFRESH: 'Aktualisierung',
|
||||
EXPORT: 'Export',
|
||||
ENTITY_NAME: 'Entitätsname',
|
||||
VALUE: 'Wert',
|
||||
SHOW_FAV: 'nur Favoriten anzeigen',
|
||||
DEVICE_SENSOR_DATA: 'Device und Sensordaten',
|
||||
DEVICES_SENSORS: 'Devices & Sensoren',
|
||||
ATTACHED_SENSORS: 'Angeschlossene EMS-ESP Sensoren',
|
||||
RUN_COMMAND: 'Befehl ausführen',
|
||||
CHANGE_VALUE: 'Wert ändern',
|
||||
CANCEL: 'Absagen',
|
||||
RESET: 'Zurücksetzen',
|
||||
SEND: 'Senden',
|
||||
SAVE: 'Speichern',
|
||||
REMOVE: 'Entfernen',
|
||||
PROBLEM_UPDATING: 'Problem beim Aktualisieren',
|
||||
ACCESS_DENIED: 'Zugriff abgelehnt',
|
||||
ANALOG_SENSOR: 'Analoger Sensor {cmd}',
|
||||
TEMP_SENSOR: 'Temperatursensor {cmd}',
|
||||
WRITE_COMMAND: 'Befehl schreiben {cmd}',
|
||||
EMS_BUS_WARNING:
|
||||
'EMS-Bus getrennt. Wenn diese Warnung nach einigen Sekunden immer noch besteht, überprüfen Sie bitte die Einstellungen und das Board-Profil',
|
||||
EMS_BUS_SCANNING: 'Scannen nach EMS devices...'
|
||||
};
|
||||
|
||||
export default de;
|
||||
49
interface/src/i18n/en/index.ts
Normal file
49
interface/src/i18n/en/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { BaseTranslation } from '../i18n-types';
|
||||
|
||||
const en: BaseTranslation = {
|
||||
LANGUAGE: 'Language',
|
||||
IS_REQUIRED: 'is required',
|
||||
SIGN_IN: 'Sign In',
|
||||
USERNAME: 'Username',
|
||||
PASSWORD: 'Password',
|
||||
DASHBOARD: 'Dashboard',
|
||||
SETTINGS: 'Settings',
|
||||
HELP: 'Help',
|
||||
LOGGED_IN: 'Logged in as {name}',
|
||||
PLEASE_SIGNIN: 'Please sign in to continue',
|
||||
UPLOAD_SUCCESSFUL: 'Upload successful',
|
||||
INVALID_LOGIN: 'Invalid login details',
|
||||
NETWORK_CONNECTION: 'Network Connection',
|
||||
SECURITY: 'Security',
|
||||
NETWORK_TIME: 'Network Time',
|
||||
ONOFF_CAP: 'ON/OFF',
|
||||
ONOFF: 'on/off',
|
||||
TYPE: 'Type',
|
||||
DESCRIPTION: 'Description',
|
||||
ENTITIES: 'Entities',
|
||||
REFRESH: 'Refresh',
|
||||
EXPORT: 'Export',
|
||||
ENTITY_NAME: 'Entity Name',
|
||||
VALUE: 'Value',
|
||||
SHOW_FAV: 'only show favorites',
|
||||
DEVICE_SENSOR_DATA: 'Device and Sensor Data',
|
||||
DEVICES_SENSORS: 'Devices & Sensors',
|
||||
ATTACHED_SENSORS: 'Attached EMS-ESP Sensors',
|
||||
RUN_COMMAND: 'Call Command',
|
||||
CHANGE_VALUE: 'Change Value',
|
||||
CANCEL: 'Cancel',
|
||||
RESET: 'Reset',
|
||||
SEND: 'Send',
|
||||
SAVE: 'Save',
|
||||
REMOVE: 'Remove',
|
||||
PROBLEM_UPDATING: 'Problem updating',
|
||||
ACCESS_DENIED: 'Access Denied',
|
||||
ANALOG_SENSOR: 'Analog Sensor {cmd}',
|
||||
TEMP_SENSOR: 'Temperature Sensor {cmd}',
|
||||
WRITE_COMMAND: 'Write command {cmd}',
|
||||
EMS_BUS_WARNING:
|
||||
'EMS bus disconnected. If this warning still persists after a few seconds please check settings and board profile',
|
||||
EMS_BUS_SCANNING: 'Scanning for EMS devices...'
|
||||
};
|
||||
|
||||
export default en;
|
||||
11
interface/src/i18n/formatters.ts
Normal file
11
interface/src/i18n/formatters.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FormattersInitializer } from 'typesafe-i18n';
|
||||
import type { Locales, Formatters } from './i18n-types';
|
||||
import { date } from 'typesafe-i18n/formatters';
|
||||
|
||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
|
||||
const formatters: Formatters = {
|
||||
weekday: date(locale, { weekday: 'long' })
|
||||
};
|
||||
|
||||
return formatters;
|
||||
};
|
||||
16
interface/src/i18n/i18n-react.tsx
Normal file
16
interface/src/i18n/i18n-react.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { useContext } from 'react'
|
||||
import { initI18nReact } from 'typesafe-i18n/react'
|
||||
import type { I18nContextType } from 'typesafe-i18n/react'
|
||||
import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types'
|
||||
import { loadedFormatters, loadedLocales } from './i18n-util'
|
||||
|
||||
const { component: TypesafeI18n, context: I18nContext } = initI18nReact<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
|
||||
|
||||
const useI18nContext = (): I18nContextType<Locales, Translations, TranslationFunctions> => useContext(I18nContext)
|
||||
|
||||
export { I18nContext, useI18nContext }
|
||||
|
||||
export default TypesafeI18n
|
||||
362
interface/src/i18n/i18n-types.ts
Normal file
362
interface/src/i18n/i18n-types.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n'
|
||||
|
||||
export type BaseTranslation = BaseTranslationType
|
||||
export type BaseLocale = 'en'
|
||||
|
||||
export type Locales =
|
||||
| 'de'
|
||||
| 'en'
|
||||
|
||||
export type Translation = RootTranslation
|
||||
|
||||
export type Translations = RootTranslation
|
||||
|
||||
type RootTranslation = {
|
||||
/**
|
||||
* Language
|
||||
*/
|
||||
LANGUAGE: string
|
||||
/**
|
||||
* is required
|
||||
*/
|
||||
IS_REQUIRED: string
|
||||
/**
|
||||
* Sign In
|
||||
*/
|
||||
SIGN_IN: string
|
||||
/**
|
||||
* Username
|
||||
*/
|
||||
USERNAME: string
|
||||
/**
|
||||
* Password
|
||||
*/
|
||||
PASSWORD: string
|
||||
/**
|
||||
* Dashboard
|
||||
*/
|
||||
DASHBOARD: string
|
||||
/**
|
||||
* Settings
|
||||
*/
|
||||
SETTINGS: string
|
||||
/**
|
||||
* Help
|
||||
*/
|
||||
HELP: string
|
||||
/**
|
||||
* Logged in as {name}
|
||||
* @param {unknown} name
|
||||
*/
|
||||
LOGGED_IN: RequiredParams<'name'>
|
||||
/**
|
||||
* Please sign in to continue
|
||||
*/
|
||||
PLEASE_SIGNIN: string
|
||||
/**
|
||||
* Upload successful
|
||||
*/
|
||||
UPLOAD_SUCCESSFUL: string
|
||||
/**
|
||||
* Invalid login details
|
||||
*/
|
||||
INVALID_LOGIN: string
|
||||
/**
|
||||
* Network Connection
|
||||
*/
|
||||
NETWORK_CONNECTION: string
|
||||
/**
|
||||
* Security
|
||||
*/
|
||||
SECURITY: string
|
||||
/**
|
||||
* Network Time
|
||||
*/
|
||||
NETWORK_TIME: string
|
||||
/**
|
||||
* ON/OFF
|
||||
*/
|
||||
ONOFF_CAP: string
|
||||
/**
|
||||
* on/off
|
||||
*/
|
||||
ONOFF: string
|
||||
/**
|
||||
* Type
|
||||
*/
|
||||
TYPE: string
|
||||
/**
|
||||
* Description
|
||||
*/
|
||||
DESCRIPTION: string
|
||||
/**
|
||||
* Entities
|
||||
*/
|
||||
ENTITIES: string
|
||||
/**
|
||||
* Refresh
|
||||
*/
|
||||
REFRESH: string
|
||||
/**
|
||||
* Export
|
||||
*/
|
||||
EXPORT: string
|
||||
/**
|
||||
* Entity Name
|
||||
*/
|
||||
ENTITY_NAME: string
|
||||
/**
|
||||
* Value
|
||||
*/
|
||||
VALUE: string
|
||||
/**
|
||||
* only show favorites
|
||||
*/
|
||||
SHOW_FAV: string
|
||||
/**
|
||||
* Device and Sensor Data
|
||||
*/
|
||||
DEVICE_SENSOR_DATA: string
|
||||
/**
|
||||
* Devices & Sensors
|
||||
*/
|
||||
DEVICES_SENSORS: string
|
||||
/**
|
||||
* Attached EMS-ESP Sensors
|
||||
*/
|
||||
ATTACHED_SENSORS: string
|
||||
/**
|
||||
* Call Command
|
||||
*/
|
||||
RUN_COMMAND: string
|
||||
/**
|
||||
* Change Value
|
||||
*/
|
||||
CHANGE_VALUE: string
|
||||
/**
|
||||
* Cancel
|
||||
*/
|
||||
CANCEL: string
|
||||
/**
|
||||
* Reset
|
||||
*/
|
||||
RESET: string
|
||||
/**
|
||||
* Send
|
||||
*/
|
||||
SEND: string
|
||||
/**
|
||||
* Save
|
||||
*/
|
||||
SAVE: string
|
||||
/**
|
||||
* Remove
|
||||
*/
|
||||
REMOVE: string
|
||||
/**
|
||||
* Problem updating
|
||||
*/
|
||||
PROBLEM_UPDATING: string
|
||||
/**
|
||||
* Access Denied
|
||||
*/
|
||||
ACCESS_DENIED: string
|
||||
/**
|
||||
* Analog Sensor {cmd}
|
||||
* @param {unknown} cmd
|
||||
*/
|
||||
ANALOG_SENSOR: RequiredParams<'cmd'>
|
||||
/**
|
||||
* Temperature Sensor {cmd}
|
||||
* @param {unknown} cmd
|
||||
*/
|
||||
TEMP_SENSOR: RequiredParams<'cmd'>
|
||||
/**
|
||||
* Write command {cmd}
|
||||
* @param {unknown} cmd
|
||||
*/
|
||||
WRITE_COMMAND: RequiredParams<'cmd'>
|
||||
/**
|
||||
* EMS bus disconnected. If this warning still persists after a few seconds please check settings and board profile
|
||||
*/
|
||||
EMS_BUS_WARNING: string
|
||||
/**
|
||||
* Scanning for EMS devices...
|
||||
*/
|
||||
EMS_BUS_SCANNING: string
|
||||
}
|
||||
|
||||
export type TranslationFunctions = {
|
||||
/**
|
||||
* Language
|
||||
*/
|
||||
LANGUAGE: () => LocalizedString
|
||||
/**
|
||||
* is required
|
||||
*/
|
||||
IS_REQUIRED: () => LocalizedString
|
||||
/**
|
||||
* Sign In
|
||||
*/
|
||||
SIGN_IN: () => LocalizedString
|
||||
/**
|
||||
* Username
|
||||
*/
|
||||
USERNAME: () => LocalizedString
|
||||
/**
|
||||
* Password
|
||||
*/
|
||||
PASSWORD: () => LocalizedString
|
||||
/**
|
||||
* Dashboard
|
||||
*/
|
||||
DASHBOARD: () => LocalizedString
|
||||
/**
|
||||
* Settings
|
||||
*/
|
||||
SETTINGS: () => LocalizedString
|
||||
/**
|
||||
* Help
|
||||
*/
|
||||
HELP: () => LocalizedString
|
||||
/**
|
||||
* Logged in as {name}
|
||||
*/
|
||||
LOGGED_IN: (arg: { name: unknown }) => LocalizedString
|
||||
/**
|
||||
* Please sign in to continue
|
||||
*/
|
||||
PLEASE_SIGNIN: () => LocalizedString
|
||||
/**
|
||||
* Upload successful
|
||||
*/
|
||||
UPLOAD_SUCCESSFUL: () => LocalizedString
|
||||
/**
|
||||
* Invalid login details
|
||||
*/
|
||||
INVALID_LOGIN: () => LocalizedString
|
||||
/**
|
||||
* Network Connection
|
||||
*/
|
||||
NETWORK_CONNECTION: () => LocalizedString
|
||||
/**
|
||||
* Security
|
||||
*/
|
||||
SECURITY: () => LocalizedString
|
||||
/**
|
||||
* Network Time
|
||||
*/
|
||||
NETWORK_TIME: () => LocalizedString
|
||||
/**
|
||||
* ON/OFF
|
||||
*/
|
||||
ONOFF_CAP: () => LocalizedString
|
||||
/**
|
||||
* on/off
|
||||
*/
|
||||
ONOFF: () => LocalizedString
|
||||
/**
|
||||
* Type
|
||||
*/
|
||||
TYPE: () => LocalizedString
|
||||
/**
|
||||
* Description
|
||||
*/
|
||||
DESCRIPTION: () => LocalizedString
|
||||
/**
|
||||
* Entities
|
||||
*/
|
||||
ENTITIES: () => LocalizedString
|
||||
/**
|
||||
* Refresh
|
||||
*/
|
||||
REFRESH: () => LocalizedString
|
||||
/**
|
||||
* Export
|
||||
*/
|
||||
EXPORT: () => LocalizedString
|
||||
/**
|
||||
* Entity Name
|
||||
*/
|
||||
ENTITY_NAME: () => LocalizedString
|
||||
/**
|
||||
* Value
|
||||
*/
|
||||
VALUE: () => LocalizedString
|
||||
/**
|
||||
* only show favorites
|
||||
*/
|
||||
SHOW_FAV: () => LocalizedString
|
||||
/**
|
||||
* Device and Sensor Data
|
||||
*/
|
||||
DEVICE_SENSOR_DATA: () => LocalizedString
|
||||
/**
|
||||
* Devices & Sensors
|
||||
*/
|
||||
DEVICES_SENSORS: () => LocalizedString
|
||||
/**
|
||||
* Attached EMS-ESP Sensors
|
||||
*/
|
||||
ATTACHED_SENSORS: () => LocalizedString
|
||||
/**
|
||||
* Call Command
|
||||
*/
|
||||
RUN_COMMAND: () => LocalizedString
|
||||
/**
|
||||
* Change Value
|
||||
*/
|
||||
CHANGE_VALUE: () => LocalizedString
|
||||
/**
|
||||
* Cancel
|
||||
*/
|
||||
CANCEL: () => LocalizedString
|
||||
/**
|
||||
* Reset
|
||||
*/
|
||||
RESET: () => LocalizedString
|
||||
/**
|
||||
* Send
|
||||
*/
|
||||
SEND: () => LocalizedString
|
||||
/**
|
||||
* Save
|
||||
*/
|
||||
SAVE: () => LocalizedString
|
||||
/**
|
||||
* Remove
|
||||
*/
|
||||
REMOVE: () => LocalizedString
|
||||
/**
|
||||
* Problem updating
|
||||
*/
|
||||
PROBLEM_UPDATING: () => LocalizedString
|
||||
/**
|
||||
* Access Denied
|
||||
*/
|
||||
ACCESS_DENIED: () => LocalizedString
|
||||
/**
|
||||
* Analog Sensor {cmd}
|
||||
*/
|
||||
ANALOG_SENSOR: (arg: { cmd: unknown }) => LocalizedString
|
||||
/**
|
||||
* Temperature Sensor {cmd}
|
||||
*/
|
||||
TEMP_SENSOR: (arg: { cmd: unknown }) => LocalizedString
|
||||
/**
|
||||
* Write command {cmd}
|
||||
*/
|
||||
WRITE_COMMAND: (arg: { cmd: unknown }) => LocalizedString
|
||||
/**
|
||||
* EMS bus disconnected. If this warning still persists after a few seconds please check settings and board profile
|
||||
*/
|
||||
EMS_BUS_WARNING: () => LocalizedString
|
||||
/**
|
||||
* Scanning for EMS devices...
|
||||
*/
|
||||
EMS_BUS_SCANNING: () => LocalizedString
|
||||
}
|
||||
|
||||
export type Formatters = {}
|
||||
27
interface/src/i18n/i18n-util.async.ts
Normal file
27
interface/src/i18n/i18n-util.async.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { initFormatters } from './formatters'
|
||||
import type { Locales, Translations } from './i18n-types'
|
||||
import { loadedFormatters, loadedLocales, locales } from './i18n-util'
|
||||
|
||||
const localeTranslationLoaders = {
|
||||
de: () => import('./de'),
|
||||
en: () => import('./en'),
|
||||
}
|
||||
|
||||
const updateDictionary = (locale: Locales, dictionary: Partial<Translations>) =>
|
||||
loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
|
||||
|
||||
export const importLocaleAsync = async (locale: Locales) =>
|
||||
(await localeTranslationLoaders[locale]()).default as unknown as Translations
|
||||
|
||||
export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
|
||||
updateDictionary(locale, await importLocaleAsync(locale))
|
||||
loadFormatters(locale)
|
||||
}
|
||||
|
||||
export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
|
||||
|
||||
export const loadFormatters = (locale: Locales): void =>
|
||||
void (loadedFormatters[locale] = initFormatters(locale))
|
||||
26
interface/src/i18n/i18n-util.sync.ts
Normal file
26
interface/src/i18n/i18n-util.sync.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { initFormatters } from './formatters'
|
||||
import type { Locales, Translations } from './i18n-types'
|
||||
import { loadedFormatters, loadedLocales, locales } from './i18n-util'
|
||||
|
||||
import de from './de'
|
||||
import en from './en'
|
||||
|
||||
const localeTranslations = {
|
||||
de,
|
||||
en,
|
||||
}
|
||||
|
||||
export const loadLocale = (locale: Locales): void => {
|
||||
if (loadedLocales[locale]) return
|
||||
|
||||
loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
|
||||
loadFormatters(locale)
|
||||
}
|
||||
|
||||
export const loadAllLocales = (): void => locales.forEach(loadLocale)
|
||||
|
||||
export const loadFormatters = (locale: Locales): void =>
|
||||
void (loadedFormatters[locale] = initFormatters(locale))
|
||||
31
interface/src/i18n/i18n-util.ts
Normal file
31
interface/src/i18n/i18n-util.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
|
||||
/* eslint-disable */
|
||||
|
||||
import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
|
||||
import type { LocaleDetector } from 'typesafe-i18n/detectors'
|
||||
import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
|
||||
import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types'
|
||||
|
||||
export const baseLocale: Locales = 'en'
|
||||
|
||||
export const locales: Locales[] = [
|
||||
'de',
|
||||
'en'
|
||||
]
|
||||
|
||||
export const loadedLocales = {} as Record<Locales, Translations>
|
||||
|
||||
export const loadedFormatters = {} as Record<Locales, Formatters>
|
||||
|
||||
export const i18nString = (locale: Locales) => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
|
||||
|
||||
export const i18nObject = (locale: Locales) =>
|
||||
initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
|
||||
locale,
|
||||
loadedLocales[locale],
|
||||
loadedFormatters[locale]
|
||||
)
|
||||
|
||||
export const i18n = () => initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
|
||||
|
||||
export const detectLocale = (...detectors: LocaleDetector[]) => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
|
||||
@@ -5,17 +5,21 @@ import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useRouterTab, useLayoutTitle } from '../components';
|
||||
|
||||
import { useI18nContext } from '../i18n/i18n-react';
|
||||
|
||||
import DashboardStatus from './DashboardStatus';
|
||||
import DashboardData from './DashboardData';
|
||||
|
||||
const Dashboard: FC = () => {
|
||||
useLayoutTitle('Dashboard');
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
useLayoutTitle(LL.DASHBOARD());
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterTabs value={routerTab}>
|
||||
<Tab value="data" label="Devices & Sensors" />
|
||||
<Tab value="data" label={LL.DEVICES_SENSORS()} />
|
||||
<Tab value="status" label="Status" />
|
||||
</RouterTabs>
|
||||
<Routes>
|
||||
|
||||
@@ -74,12 +74,21 @@ import {
|
||||
DeviceEntityMask
|
||||
} from './types';
|
||||
|
||||
import { useI18nContext } from '../i18n/i18n-react';
|
||||
|
||||
const DashboardData: FC = () => {
|
||||
const { me } = useContext(AuthenticatedContext);
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [coreData, setCoreData] = useState<CoreData>({ connected: true, devices: [], active_sensors: 0, analog_enabled: false });
|
||||
const [coreData, setCoreData] = useState<CoreData>({
|
||||
connected: true,
|
||||
devices: [],
|
||||
active_sensors: 0,
|
||||
analog_enabled: false
|
||||
});
|
||||
const [deviceData, setDeviceData] = useState<DeviceData>({ label: '', data: [] });
|
||||
const [sensorData, setSensorData] = useState<SensorData>({ sensors: [], analogs: [] });
|
||||
const [deviceValue, setDeviceValue] = useState<DeviceValue>();
|
||||
@@ -134,7 +143,7 @@ const DashboardData: FC = () => {
|
||||
common_theme,
|
||||
{
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 40px 100px repeat(1, minmax(0, 1fr)) 80px 40px;
|
||||
--data-table-library_grid-template-columns: 40px 100px repeat(1, minmax(0, 1fr)) 90px 40px;
|
||||
`,
|
||||
BaseRow: `
|
||||
.td {
|
||||
@@ -429,15 +438,15 @@ const DashboardData: FC = () => {
|
||||
devicevalue: deviceValue
|
||||
});
|
||||
if (response.status === 204) {
|
||||
enqueueSnackbar('Write command failed', { variant: 'error' });
|
||||
enqueueSnackbar(LL.WRITE_COMMAND({ cmd: 'failed' }), { variant: 'error' });
|
||||
} else if (response.status === 403) {
|
||||
enqueueSnackbar('Write access denied', { variant: 'error' });
|
||||
enqueueSnackbar(LL.ACCESS_DENIED(), { variant: 'error' });
|
||||
} else {
|
||||
enqueueSnackbar('Write command sent', { variant: 'success' });
|
||||
enqueueSnackbar(LL.WRITE_COMMAND({ cmd: 'send' }), { variant: 'success' });
|
||||
}
|
||||
setDeviceValue(undefined);
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Problem writing value'), { variant: 'error' });
|
||||
enqueueSnackbar(extractErrorMessage(error, LL.PROBLEM_UPDATING()), { variant: 'error' });
|
||||
} finally {
|
||||
refreshData();
|
||||
setDeviceValue(undefined);
|
||||
@@ -449,7 +458,7 @@ const DashboardData: FC = () => {
|
||||
if (deviceValue) {
|
||||
return (
|
||||
<Dialog open={deviceValue !== undefined} onClose={() => setDeviceValue(undefined)}>
|
||||
<DialogTitle>{isCmdOnly(deviceValue) ? 'Run Command' : 'Change Value'}</DialogTitle>
|
||||
<DialogTitle>{isCmdOnly(deviceValue) ? LL.RUN_COMMAND() : LL.CHANGE_VALUE()}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{deviceValue.l && (
|
||||
<ValidatedTextField
|
||||
@@ -491,7 +500,7 @@ const DashboardData: FC = () => {
|
||||
onClick={() => setDeviceValue(undefined)}
|
||||
color="secondary"
|
||||
>
|
||||
Cancel
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SendIcon />}
|
||||
@@ -500,7 +509,7 @@ const DashboardData: FC = () => {
|
||||
onClick={() => sendDeviceValue()}
|
||||
color="warning"
|
||||
>
|
||||
Send
|
||||
{LL.SEND()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -521,15 +530,15 @@ const DashboardData: FC = () => {
|
||||
offset: sensor.o
|
||||
});
|
||||
if (response.status === 204) {
|
||||
enqueueSnackbar('Sensor change failed', { variant: 'error' });
|
||||
enqueueSnackbar(LL.TEMP_SENSOR({ cmd: 'change failed' }), { variant: 'error' });
|
||||
} else if (response.status === 403) {
|
||||
enqueueSnackbar('Access denied', { variant: 'error' });
|
||||
enqueueSnackbar(LL.ACCESS_DENIED(), { variant: 'error' });
|
||||
} else {
|
||||
enqueueSnackbar('Sensor updated', { variant: 'success' });
|
||||
enqueueSnackbar(LL.TEMP_SENSOR({ cmd: 'removed' }), { variant: 'success' });
|
||||
}
|
||||
setSensor(undefined);
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Problem updating sensor'), { variant: 'error' });
|
||||
enqueueSnackbar(extractErrorMessage(error, LL.PROBLEM_UPDATING()), { variant: 'error' });
|
||||
} finally {
|
||||
setSensor(undefined);
|
||||
fetchSensorData();
|
||||
@@ -581,7 +590,7 @@ const DashboardData: FC = () => {
|
||||
onClick={() => setSensor(undefined)}
|
||||
color="secondary"
|
||||
>
|
||||
Cancel
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SaveIcon />}
|
||||
@@ -590,7 +599,7 @@ const DashboardData: FC = () => {
|
||||
onClick={() => sendSensor()}
|
||||
color="warning"
|
||||
>
|
||||
Save
|
||||
{LL.SAVE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -640,17 +649,20 @@ const DashboardData: FC = () => {
|
||||
|
||||
const renderCoreData = () => (
|
||||
<IconContext.Provider value={{ color: 'lightblue', size: '24', style: { verticalAlign: 'middle' } }}>
|
||||
{!coreData.connected && <MessageBox my={2} level="error" message="EMSbus disconnected, check settings and board profile" />}
|
||||
{coreData.connected && coreData.devices.length === 0 && <MessageBox my={2} level="warning" message="Scanning for EMS devices..." />}
|
||||
{!coreData.connected && <MessageBox my={2} level="error" message={LL.EMS_BUS_WARNING()} />}
|
||||
{coreData.connected && coreData.devices.length === 0 && (
|
||||
<MessageBox my={2} level="warning" message={LL.EMS_BUS_SCANNING()} />
|
||||
)}
|
||||
|
||||
<Table data={{ nodes: coreData.devices }} select={device_select} theme={device_theme} layout={{ custom: true }}>
|
||||
{(tableList: any) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff />
|
||||
<HeaderCell stiff>TYPE</HeaderCell>
|
||||
<HeaderCell resize>DESCRIPTION</HeaderCell>
|
||||
<HeaderCell stiff>ENTITIES</HeaderCell>
|
||||
<HeaderCell stiff>{LL.TYPE()}</HeaderCell>
|
||||
<HeaderCell resize>{LL.DESCRIPTION()}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.ENTITIES()}</HeaderCell>
|
||||
<HeaderCell stiff />
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
@@ -676,7 +688,7 @@ const DashboardData: FC = () => {
|
||||
<DeviceIcon type="Sensor" />
|
||||
</Cell>
|
||||
<Cell>Sensors</Cell>
|
||||
<Cell>Attached EMS-ESP Sensors</Cell>
|
||||
<Cell>{LL.ATTACHED_SENSORS()}</Cell>
|
||||
<Cell>{coreData.active_sensors}</Cell>
|
||||
<Cell>
|
||||
<IconButton size="small" onClick={() => addAnalogSensor()}>
|
||||
@@ -723,7 +735,7 @@ const DashboardData: FC = () => {
|
||||
control={<Checkbox size="small" name="onlyFav" checked={onlyFav} onChange={() => setOnlyFav(!onlyFav)} />}
|
||||
label={
|
||||
<span style={{ fontSize: '12px' }}>
|
||||
only show favorites
|
||||
{LL.SHOW_FAV()}
|
||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||
</span>
|
||||
}
|
||||
@@ -749,7 +761,7 @@ const DashboardData: FC = () => {
|
||||
endIcon={getSortIcon(dv_sort.state, 'NAME')}
|
||||
onClick={() => dv_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||
>
|
||||
ENTITY NAME
|
||||
{LL.ENTITY_NAME()}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell resize>
|
||||
@@ -759,7 +771,7 @@ const DashboardData: FC = () => {
|
||||
endIcon={getSortIcon(dv_sort.state, 'VALUE')}
|
||||
onClick={() => dv_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
||||
>
|
||||
VALUE
|
||||
{LL.VALUE()}
|
||||
</Button>
|
||||
</HeaderCell>
|
||||
<HeaderCell stiff />
|
||||
@@ -943,14 +955,14 @@ const DashboardData: FC = () => {
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
enqueueSnackbar('Analog deletion failed', { variant: 'error' });
|
||||
enqueueSnackbar(LL.ANALOG_SENSOR({ cmd: 'deletion failed' }), { variant: 'error' });
|
||||
} else if (response.status === 403) {
|
||||
enqueueSnackbar('Access denied', { variant: 'error' });
|
||||
enqueueSnackbar(LL.ACCESS_DENIED(), { variant: 'error' });
|
||||
} else {
|
||||
enqueueSnackbar('Analog sensor removed', { variant: 'success' });
|
||||
enqueueSnackbar(LL.ANALOG_SENSOR({ cmd: 'removed' }), { variant: 'success' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Problem updating analog sensor'), { variant: 'error' });
|
||||
enqueueSnackbar(extractErrorMessage(error, LL.PROBLEM_UPDATING()), { variant: 'error' });
|
||||
} finally {
|
||||
setAnalog(undefined);
|
||||
fetchSensorData();
|
||||
@@ -971,14 +983,14 @@ const DashboardData: FC = () => {
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
enqueueSnackbar('Analog sensor update failed', { variant: 'error' });
|
||||
enqueueSnackbar(LL.ANALOG_SENSOR({ cmd: 'update failed' }), { variant: 'error' });
|
||||
} else if (response.status === 403) {
|
||||
enqueueSnackbar('Access denied', { variant: 'error' });
|
||||
enqueueSnackbar(LL.ACCESS_DENIED(), { variant: 'error' });
|
||||
} else {
|
||||
enqueueSnackbar('Analog sensor updated', { variant: 'success' });
|
||||
enqueueSnackbar(LL.ANALOG_SENSOR({ cmd: 'updated' }), { variant: 'success' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(extractErrorMessage(error, 'Problem updating analog'), { variant: 'error' });
|
||||
enqueueSnackbar(extractErrorMessage(error, LL.PROBLEM_UPDATING()), { variant: 'error' });
|
||||
} finally {
|
||||
setAnalog(undefined);
|
||||
fetchSensorData();
|
||||
@@ -1131,7 +1143,7 @@ const DashboardData: FC = () => {
|
||||
<Grid item>
|
||||
<ValidatedTextField
|
||||
name="o"
|
||||
label="Dutycycle"
|
||||
label="Duty Cycle"
|
||||
value={numberValue(analog.o)}
|
||||
sx={{ width: '20ch' }}
|
||||
type="number"
|
||||
@@ -1153,7 +1165,7 @@ const DashboardData: FC = () => {
|
||||
<DialogActions>
|
||||
<Box flexGrow={1} sx={{ '& button': { mt: 0 } }}>
|
||||
<Button startIcon={<RemoveIcon />} variant="outlined" color="error" onClick={() => sendRemoveAnalog()}>
|
||||
Remove
|
||||
{LL.REMOVE()}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -1162,7 +1174,7 @@ const DashboardData: FC = () => {
|
||||
onClick={() => setAnalog(undefined)}
|
||||
color="secondary"
|
||||
>
|
||||
Cancel
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SaveIcon />}
|
||||
@@ -1171,7 +1183,7 @@ const DashboardData: FC = () => {
|
||||
onClick={() => sendAnalog()}
|
||||
color="warning"
|
||||
>
|
||||
Save
|
||||
{LL.SAVE()}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
@@ -1180,7 +1192,7 @@ const DashboardData: FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionContent title="Device and Sensor Data" titleGutter>
|
||||
<SectionContent title={LL.DEVICE_SENSOR_DATA()} titleGutter>
|
||||
{renderCoreData()}
|
||||
{renderDeviceData()}
|
||||
{renderDeviceDialog()}
|
||||
@@ -1191,11 +1203,11 @@ const DashboardData: FC = () => {
|
||||
{renderAnalogDialog()}
|
||||
<ButtonRow>
|
||||
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={refreshData}>
|
||||
Refresh
|
||||
{LL.REFRESH()}
|
||||
</Button>
|
||||
{device_select.state.id && device_select.state.id !== 'sensor' && (
|
||||
<Button startIcon={<DownloadIcon />} variant="outlined" onClick={handleDownloadCsv}>
|
||||
Export
|
||||
{LL.EXPORT()}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonRow>
|
||||
|
||||
@@ -5,12 +5,16 @@ import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useRouterTab, useLayoutTitle } from '../components';
|
||||
|
||||
import { useI18nContext } from '../i18n/i18n-react';
|
||||
|
||||
import HelpInformation from './HelpInformation';
|
||||
|
||||
const Help: FC = () => {
|
||||
useLayoutTitle('Help');
|
||||
const { LL } = useI18nContext();
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
useLayoutTitle(LL.HELP());
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterTabs value={routerTab}>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AuthenticatedContext } from '../contexts/authentication';
|
||||
|
||||
import { PROJECT_PATH } from '../api/env';
|
||||
|
||||
import { useI18nContext } from '../i18n/i18n-react';
|
||||
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import LayoutMenuItem from '../components/layout/LayoutMenuItem';
|
||||
@@ -13,17 +15,18 @@ import InfoIcon from '@mui/icons-material/Info';
|
||||
|
||||
const ProjectMenu: FC = () => {
|
||||
const authenticatedContext = useContext(AuthenticatedContext);
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
return (
|
||||
<List>
|
||||
<LayoutMenuItem icon={DashboardIcon} label="Dashboard" to={`/${PROJECT_PATH}/dashboard`} />
|
||||
<LayoutMenuItem icon={DashboardIcon} label={LL.DASHBOARD()} to={`/${PROJECT_PATH}/dashboard`} />
|
||||
<LayoutMenuItem
|
||||
icon={TuneIcon}
|
||||
label="Settings"
|
||||
label={LL.SETTINGS()}
|
||||
to={`/${PROJECT_PATH}/settings`}
|
||||
disabled={!authenticatedContext.me.admin}
|
||||
/>
|
||||
<LayoutMenuItem icon={InfoIcon} label="Help" to={`/${PROJECT_PATH}/help`} />
|
||||
<LayoutMenuItem icon={InfoIcon} label={LL.HELP()} to={`/${PROJECT_PATH}/help`} />
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,13 +5,17 @@ import { Tab } from '@mui/material';
|
||||
|
||||
import { RouterTabs, useRouterTab, useLayoutTitle } from '../components';
|
||||
|
||||
import { useI18nContext } from '../i18n/i18n-react';
|
||||
|
||||
import SettingsApplication from './SettingsApplication';
|
||||
import SettingsCustomization from './SettingsCustomization';
|
||||
|
||||
const Settings: FC = () => {
|
||||
useLayoutTitle('Settings');
|
||||
const { LL } = useI18nContext();
|
||||
const { routerTab } = useRouterTab();
|
||||
|
||||
useLayoutTitle(LL.SETTINGS());
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterTabs value={routerTab}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ValidateFieldsError } from 'async-validator';
|
||||
|
||||
import { useSnackbar } from 'notistack';
|
||||
|
||||
import { Box, Button, Checkbox, MenuItem, Grid, Typography, Divider } from '@mui/material';
|
||||
import { Box, Button, Checkbox, MenuItem, Grid, Typography, Divider, InputAdornment } from '@mui/material';
|
||||
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
@@ -24,6 +24,8 @@ import { numberValue, extractErrorMessage, updateValue, useRest } from '../utils
|
||||
import * as EMSESP from './api';
|
||||
import { Settings, BOARD_PROFILES } from './types';
|
||||
|
||||
import { useI18nContext } from '../i18n/i18n-react';
|
||||
|
||||
export function boardProfileSelectItems() {
|
||||
return Object.keys(BOARD_PROFILES).map((code) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
@@ -38,6 +40,8 @@ const SettingsApplication: FC = () => {
|
||||
update: EMSESP.writeSettings
|
||||
});
|
||||
|
||||
const { LL } = useI18nContext();
|
||||
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const updateFormValue = updateValue(setData);
|
||||
@@ -116,7 +120,7 @@ const SettingsApplication: FC = () => {
|
||||
<Box color="warning.main">
|
||||
<Typography variant="body2">
|
||||
Select a pre-configured interface board profile from the list below or choose "Custom" to configure your own
|
||||
hardware settings.
|
||||
hardware settings
|
||||
</Typography>
|
||||
</Box>
|
||||
<ValidatedTextField
|
||||
@@ -321,6 +325,26 @@ const SettingsApplication: FC = () => {
|
||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
||||
General Options
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
'& .MuiTextField-root': { width: '25ch' }
|
||||
}}
|
||||
>
|
||||
<ValidatedTextField
|
||||
name="locale"
|
||||
label="Language (for device entities)"
|
||||
disabled={saving}
|
||||
value={data.locale}
|
||||
variant="outlined"
|
||||
onChange={updateFormValue}
|
||||
margin="normal"
|
||||
size="small"
|
||||
select
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Box>
|
||||
{data.led_gpio !== 0 && (
|
||||
<BlockFormControlLabel
|
||||
control={<Checkbox checked={data.hide_led} onChange={updateFormValue} name="hide_led" />}
|
||||
@@ -350,7 +374,7 @@ const SettingsApplication: FC = () => {
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={<Checkbox checked={data.readonly_mode} onChange={updateFormValue} name="readonly_mode" />}
|
||||
label="Enable Read only mode (blocks all outgoing EMS Tx write commands)"
|
||||
label="Enable read-only mode (blocks all outgoing EMS Tx Write commands)"
|
||||
disabled={saving}
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
@@ -371,11 +395,14 @@ const SettingsApplication: FC = () => {
|
||||
/>
|
||||
{data.shower_alert && (
|
||||
<>
|
||||
<Grid item xs={2}>
|
||||
<Grid item xs={4}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="shower_alert_trigger"
|
||||
label="Trigger Time (minutes)"
|
||||
label="Trigger Time"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
|
||||
}}
|
||||
variant="outlined"
|
||||
value={data.shower_alert_trigger}
|
||||
type="number"
|
||||
@@ -383,11 +410,14 @@ const SettingsApplication: FC = () => {
|
||||
disabled={!data.shower_timer}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
<Grid item xs={4}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="shower_alert_coldshot"
|
||||
label="Cold Shot Time (seconds)"
|
||||
label="Cold Shot Duration"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
variant="outlined"
|
||||
value={data.shower_alert_coldshot}
|
||||
type="number"
|
||||
@@ -413,8 +443,8 @@ const SettingsApplication: FC = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={1}>on/off</MenuItem>
|
||||
<MenuItem value={2}>ON/OFF</MenuItem>
|
||||
<MenuItem value={1}>{LL.ONOFF()}</MenuItem>
|
||||
<MenuItem value={2}>{LL.ONOFF_CAP()}</MenuItem>
|
||||
<MenuItem value={3}>true/false</MenuItem>
|
||||
<MenuItem value={5}>1/0</MenuItem>
|
||||
</ValidatedTextField>
|
||||
@@ -430,8 +460,8 @@ const SettingsApplication: FC = () => {
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem value={1}>"on"/"off"</MenuItem>
|
||||
<MenuItem value={2}>"ON"/"OFF"</MenuItem>
|
||||
<MenuItem value={1}>{LL.ONOFF()}</MenuItem>
|
||||
<MenuItem value={2}>{LL.ONOFF_CAP()}</MenuItem>
|
||||
<MenuItem value={3}>"true"/"false"</MenuItem>
|
||||
<MenuItem value={4}>true/false</MenuItem>
|
||||
<MenuItem value={5}>"1"/"0"</MenuItem>
|
||||
@@ -487,7 +517,7 @@ const SettingsApplication: FC = () => {
|
||||
/>
|
||||
{data.syslog_enabled && (
|
||||
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
|
||||
<Grid item xs={5}>
|
||||
<Grid item xs={4}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="syslog_host"
|
||||
@@ -500,7 +530,7 @@ const SettingsApplication: FC = () => {
|
||||
disabled={saving}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={4}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="syslog_port"
|
||||
@@ -514,7 +544,7 @@ const SettingsApplication: FC = () => {
|
||||
disabled={saving}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={5}>
|
||||
<Grid item xs={4}>
|
||||
<ValidatedTextField
|
||||
name="syslog_level"
|
||||
label="Log Level"
|
||||
@@ -534,11 +564,14 @@ const SettingsApplication: FC = () => {
|
||||
<MenuItem value={9}>ALL</MenuItem>
|
||||
</ValidatedTextField>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={4}>
|
||||
<ValidatedTextField
|
||||
fieldErrors={fieldErrors}
|
||||
name="syslog_mark_interval"
|
||||
label="Mark Interval (seconds, 0=off)"
|
||||
label="Mark Interval"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">seconds</InputAdornment>
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.syslog_mark_interval}
|
||||
|
||||
@@ -294,7 +294,7 @@ const SettingsCustomization: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Box mb={2} color="warning.main">
|
||||
<Typography variant="body2">Select a device and customize each of its entities using the options:</Typography>
|
||||
<Typography variant="body2">Select a device and customize the entities using the options:</Typography>
|
||||
<Typography variant="body2">
|
||||
<OptionIcon type="favorite" isSet={true} />
|
||||
=mark as favorite
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface Settings {
|
||||
locale: string;
|
||||
tx_mode: number;
|
||||
ems_bus_id: number;
|
||||
syslog_enabled: boolean;
|
||||
|
||||
@@ -3,10 +3,8 @@ import Schema from 'async-validator';
|
||||
export const SIGN_IN_REQUEST_VALIDATOR = new Schema({
|
||||
username: {
|
||||
required: true,
|
||||
message: 'Please provide a username'
|
||||
},
|
||||
password: {
|
||||
required: true,
|
||||
message: 'Please provide a password'
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user