Login/Logout + Menu + i18next

This commit is contained in:
2024-07-25 01:09:20 +02:00
parent dd48f72c42
commit 65848f094b
17 changed files with 1042 additions and 211 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-B-6Z6srI.js"></script> <script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-CCiAEYtb.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+11
View File
@@ -0,0 +1,11 @@
{
"NotFound_user:login": "Benutzer existiert nicht",
"Unauthorized:login": "Ungültige E-mail oder Passwort",
"GuestBook": "Gästebuch",
"E-Mail": "E-Mail",
"Password": "Passwort",
"Log in": "Anmelden",
"Log out": "Abmelden",
"Profile": "Profil"
}
+11
View File
@@ -0,0 +1,11 @@
{
"NotFound_user:login": "User does not exist",
"Unauthorized:login": "Invalid e-mail or password",
"GuestBook": "GuestBook",
"E-Mail": "E-Mail",
"Password": "Password",
"Log in": "Log in",
"Log out": "Log out",
"Profile": "Profile"
}
+7 -1
View File
@@ -12,19 +12,25 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.4",
"@mui/material": "^5.16.4", "@mui/material": "^5.16.4",
"@tanstack/react-form": "^0.26.4",
"@tanstack/react-query": "^5.51.11", "@tanstack/react-query": "^5.51.11",
"@tanstack/react-router": "^1.45.8", "@tanstack/react-router": "^1.45.8",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"i18next": "^23.12.2",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.5.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.0.0",
"zustand": "^4.5.4" "zustand": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@redux-devtools/extension": "^3.3.0", "@redux-devtools/extension": "^3.3.0",
"@tanstack/eslint-plugin-query": "^5.51.12", "@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/router-devtools": "^1.45.8",
"@tanstack/react-query-devtools": "^5.51.11", "@tanstack/react-query-devtools": "^5.51.11",
"@tanstack/router-devtools": "^1.45.8",
"@tanstack/router-plugin": "^1.45.8", "@tanstack/router-plugin": "^1.45.8",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
+547 -3
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
{
"NotFound_user:login": "Benutzer existiert nicht",
"Unauthorized:login": "Ungültige E-mail oder Passwort",
"GuestBook": "Gästebuch",
"E-Mail": "E-Mail",
"Password": "Passwort",
"Log in": "Anmelden",
"Log out": "Abmelden",
"Profile": "Profil"
}
@@ -0,0 +1,11 @@
{
"NotFound_user:login": "User does not exist",
"Unauthorized:login": "Invalid e-mail or password",
"GuestBook": "GuestBook",
"E-Mail": "E-Mail",
"Password": "Password",
"Log in": "Log in",
"Log out": "Log out",
"Profile": "Profile"
}
+1
View File
@@ -12,6 +12,7 @@ class ApiImpl {
throw new Error('New instance cannot be created!!'); throw new Error('New instance cannot be created!!');
} }
// eslint-disable-next-line @typescript-eslint/no-this-alias
instance = this; instance = this;
} }
@@ -0,0 +1,115 @@
import { Box, Button, TextField, Typography } from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import useGuestBookStore from '../../../store/store';
import handleError from '../../../utils/errors';
const Login: FC = () => {
const [error, setError] = useState();
const setUser = useGuestBookStore((state) => state.setUser);
const { t } = useTranslation();
const form = useForm({
defaultValues: {
email: '',
password: '',
},
onSubmit: async ({ value }) => {
try {
setUser(await Api.logIn(value.email, value.password));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setError(error);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
noValidate
>
<Box sx={{ display: 'grid', gap: 1, padding: 1, minWidth: '100px' }}>
<form.Field
name="email"
validators={{
onChange: ({ value }) => (!value ? 'Email required' : undefined),
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return value.includes('error') && 'No "error" allowed in email';
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('E-Mail')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
/>
</>
);
}}
/>
<form.Field
name="password"
validators={{
onChange: ({ value }) => (!value ? 'Password required' : undefined),
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return value.includes('error') && 'No "error" allowed in password';
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Password')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
/>
</>
);
}}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit]) => (
<>
<Button type="submit" disabled={!canSubmit} variant="contained">
{t('Log in')}
</Button>
</>
)}
/>
<Typography color="error.main">{t(...handleError(error, 'login'))}</Typography>
</Box>
</form>
);
};
export default Login;
@@ -0,0 +1,92 @@
import { AccountCircle } from '@mui/icons-material';
import { AppBar, Avatar, IconButton, Menu, MenuItem, Toolbar, Typography, useScrollTrigger } from '@mui/material';
import { cloneElement, FC, ReactElement, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import useGuestBookStore from '../../store/store';
import Login from '../Forms/Login/Login';
const ElevationScroll = ({ children }: { children: ReactElement }) => {
const trigger = useScrollTrigger({
disableHysteresis: true,
threshold: 0,
});
return cloneElement(children, {
elevation: trigger ? 4 : 0,
});
};
const Header: FC = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const user = useGuestBookStore((state) => state.user);
const setUser = useGuestBookStore((state) => state.setUser);
const { t } = useTranslation();
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<ElevationScroll>
<AppBar>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
{t('GuestBook')}
</Typography>
{user ? (
<IconButton onClick={handleMenu} sx={{ p: 0 }}>
<Avatar alt={user.username} src={`storage/${user.image}`} />
</IconButton>
) : (
<IconButton size="large" onClick={handleMenu} color="inherit">
<AccountCircle />
</IconButton>
)}
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
>
{user ? (
[
<MenuItem key="profile" onClick={handleClose}>
{t('Profile')}
</MenuItem>,
<MenuItem
key="logout"
onClick={() => {
Api.logOut();
setUser(undefined);
}}
>
{t('Log out')}
</MenuItem>,
]
) : (
<Login />
)}
</Menu>
</Toolbar>
</AppBar>
</ElevationScroll>
);
};
export default Header;
+35
View File
@@ -0,0 +1,35 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
// don't want to use this?
// have a look at the Quick start guide
// for passing in lng and translations on init
i18n
// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales)
// learn more: https://github.com/i18next/i18next-http-backend
// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn
.use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: 'en',
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: '/phpCourse/exam/dist/locales/{{lng}}/{{ns}}.json',
},
});
export default i18n;
+3
View File
@@ -7,6 +7,9 @@ import ReactDOM from 'react-dom/client';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { routeTree } from './routeTree.gen'; import { routeTree } from './routeTree.gen';
// Import i18n
import './i18n';
// Query Client // Query Client
const queryClient = new QueryClient(); const queryClient = new QueryClient();
+5 -10
View File
@@ -1,19 +1,14 @@
import { Toolbar } from '@mui/material';
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router'; import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools'; import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import Header from '../components/Header/Header';
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: () => ( component: () => (
<> <>
<Link <Header />
to="/" <Toolbar />
activeProps={{
className: 'font-bold',
}}
activeOptions={{ exact: true }}
>
Home
</Link>
<Outlet /> <Outlet />
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />} {process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</> </>
+1 -32
View File
@@ -1,40 +1,9 @@
import { Button } from '@mui/material';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import Api from '../api/Api';
import useGuestBookStore from '../store/store';
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Home, component: Home,
}); });
function Home() { function Home() {
const setUser = useGuestBookStore((state) => state.setUser); return <></>;
return (
<>
<Button
onClick={async () => {
try {
setUser(await Api.logIn('max@moritz.net', 'max'));
} catch (error) {
console.log(error);
}
}}
>
Log In
</Button>
<Button
onClick={async () => {
try {
await Api.logOut();
setUser(undefined);
} catch (error) {
console.log(error);
}
}}
>
Log Out
</Button>
</>
);
} }
+25
View File
@@ -0,0 +1,25 @@
/**
* Transform error into i18next spreadable input
* @param error Error object
* @param context Optional context for translation
* @returns Array to be spread into i18next `t` function
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleError = (error: any, context?: string): [string, { context?: string }] => {
if (!error) return ['', {}];
if (error.code) {
switch (error.code) {
case 'NotFound':
return [error.code, { context: `${error.entity}:${context}` }];
case 'Unauthorized':
return [error.code, { context }];
default:
return ['Unknown', { context }];
}
}
return [error?.message ?? 'Unknown', { context }];
};
export default handleError;