Login/Logout + Menu + i18next

This commit is contained in:
Kilian Hofmann 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

166
exam/dist/assets/index-CCiAEYtb.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div id="root"></div>

11
exam/dist/locales/de/translation.json vendored Normal file
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
exam/dist/locales/en/translation.json vendored Normal file
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"
}

View File

@ -12,19 +12,25 @@
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.4",
"@mui/material": "^5.16.4",
"@tanstack/react-form": "^0.26.4",
"@tanstack/react-query": "^5.51.11",
"@tanstack/react-router": "^1.45.8",
"@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-dom": "^18.3.1",
"react-i18next": "^15.0.0",
"zustand": "^4.5.4"
},
"devDependencies": {
"@redux-devtools/extension": "^3.3.0",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/router-devtools": "^1.45.8",
"@tanstack/react-query-devtools": "^5.51.11",
"@tanstack/router-devtools": "^1.45.8",
"@tanstack/router-plugin": "^1.45.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

File diff suppressed because it is too large Load Diff

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"
}

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"
}

View File

@ -12,6 +12,7 @@ class ApiImpl {
throw new Error('New instance cannot be created!!');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
instance = this;
}

View File

@ -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;

View File

@ -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
exam/react/src/i18n.ts Normal file
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;

View File

@ -7,6 +7,9 @@ import ReactDOM from 'react-dom/client';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { routeTree } from './routeTree.gen';
// Import i18n
import './i18n';
// Query Client
const queryClient = new QueryClient();

View File

@ -1,19 +1,14 @@
import { Toolbar } from '@mui/material';
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 Header from '../components/Header/Header';
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: () => (
<>
<Link
to="/"
activeProps={{
className: 'font-bold',
}}
activeOptions={{ exact: true }}
>
Home
</Link>
<Header />
<Toolbar />
<Outlet />
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</>

View File

@ -1,40 +1,9 @@
import { Button } from '@mui/material';
import { createFileRoute } from '@tanstack/react-router';
import Api from '../api/Api';
import useGuestBookStore from '../store/store';
export const Route = createFileRoute('/')({
component: Home,
});
function Home() {
const setUser = useGuestBookStore((state) => state.setUser);
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>
</>
);
return <></>;
}

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;