Skip to content

Commit

Permalink
Multi-language/I18n support #22
Browse files Browse the repository at this point in the history
  • Loading branch information
proddy committed Aug 24, 2022
1 parent 763337d commit 1a4ce64
Show file tree
Hide file tree
Showing 84 changed files with 5,499 additions and 4,189 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pre_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
python -m pip install --upgrade pip
pip install -U platformio
platformio upgrade
platformio update
pio pkg update
- name: Build WebUI
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tagged_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
python -m pip install --upgrade pip
pip install -U platformio
platformio upgrade
platformio update
pio pkg update
- name: Build WebUI
run: |
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG_LATEST.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

# [3.5.0]

## Added

- Translations in Web UI and all device entity names to German. [#22](https://github.com/emsesp/EMS-ESP32/issues/22)

## Fixed

## Changed

## **BREAKING CHANGES:**

# [3.4.2]

## Added
Expand Down
6 changes: 6 additions & 0 deletions esp32_partition_16M.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, , 0x2000,
app0, app, ota_0, , 0x7F0000,
app1, app, ota_1, , 0x7F0000,
spiffs, data, spiffs, , 64K,
6 changes: 6 additions & 0 deletions esp32_partition_4M.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, , 0x2000,
app0, app, ota_0, , 0x1F0000,
app1, app, ota_1, , 0x1F0000,
spiffs, data, spiffs, , 64K,
6 changes: 0 additions & 6 deletions esp32_partition_app1984k_spiffs64k.csv

This file was deleted.

5 changes: 5 additions & 0 deletions interface/.typesafe-i18n.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"adapter": "react",
"baseLocale": "en",
"$schema": "https://unpkg.com/typesafe-i18n@5.12.0/schema/typesafe-i18n.json"
}
3,206 changes: 1,897 additions & 1,309 deletions interface/package-lock.json

Large diffs are not rendered by default.

20 changes: 11 additions & 9 deletions interface/package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"name": "EMS-ESP",
"version": "3.4.0",
"version": "3.5.0",
"private": true,
"proxy": "http://localhost:3080",
"dependencies": {
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@msgpack/msgpack": "^2.7.2",
"@mui/icons-material": "^5.8.4",
"@mui/material": "^5.9.3",
"@table-library/react-table-library": "4.0.10",
"@types/lodash": "^4.14.182",
"@types/node": "^18.6.3",
"@types/react": "^18.0.15",
"@mui/icons-material": "^5.10.2",
"@mui/material": "^5.10.2",
"@table-library/react-table-library": "4.0.12",
"@types/lodash": "^4.14.184",
"@types/node": "^18.7.13",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@types/react-router-dom": "^5.3.3",
"async-validator": "^4.2.5",
Expand All @@ -30,6 +30,7 @@
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"sockette": "^2.0.6",
"typesafe-i18n": "^5.12.0",
"typescript": "^4.7.4"
},
"scripts": {
Expand All @@ -41,8 +42,9 @@
"build-hosted": "env-cmd -f .env.hosted npm run build",
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
"mock-api": "nodemon --watch ../mock-api ../mock-api/server.js",
"standalone": "npm-run-all -p start mock-api",
"lint": "eslint . --ext .ts,.tsx"
"standalone": "npm-run-all -p start typesafe-i18n mock-api",
"lint": "eslint . --ext .ts,.tsx",
"typesafe-i18n": "typesafe-i18n"
},
"eslintConfig": {
"extends": [
Expand Down
51 changes: 34 additions & 17 deletions interface/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();

Expand All @@ -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>
);
};
Expand Down
7 changes: 5 additions & 2 deletions interface/src/AppRouting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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="/"
Expand Down
51 changes: 45 additions & 6 deletions interface/src/SignIn.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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' });
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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}
Expand All @@ -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>
Expand Down
9 changes: 6 additions & 3 deletions interface/src/components/layout/LayoutMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand All @@ -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>
</>
Expand Down
6 changes: 5 additions & 1 deletion interface/src/contexts/authentication/Authentication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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');
Expand Down
Loading

0 comments on commit 1a4ce64

Please sign in to comment.