Login/Logout + Menu + i18next
This commit is contained in:
parent
dd48f72c42
commit
65848f094b
164
exam/dist/assets/index-B-6Z6srI.js
vendored
164
exam/dist/assets/index-B-6Z6srI.js
vendored
File diff suppressed because one or more lines are too long
166
exam/dist/assets/index-CCiAEYtb.js
vendored
Normal file
166
exam/dist/assets/index-CCiAEYtb.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
exam/dist/index.html
vendored
2
exam/dist/index.html
vendored
@ -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
11
exam/dist/locales/de/translation.json
vendored
Normal 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
11
exam/dist/locales/en/translation.json
vendored
Normal 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"
|
||||
}
|
||||
@ -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",
|
||||
|
||||
550
exam/react/pnpm-lock.yaml
generated
550
exam/react/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
11
exam/react/public/locales/de/translation.json
Normal file
11
exam/react/public/locales/de/translation.json
Normal 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/react/public/locales/en/translation.json
Normal file
11
exam/react/public/locales/en/translation.json
Normal 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"
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
115
exam/react/src/components/Forms/Login/Login.tsx
Normal file
115
exam/react/src/components/Forms/Login/Login.tsx
Normal 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;
|
||||
92
exam/react/src/components/Header/Header.tsx
Normal file
92
exam/react/src/components/Header/Header.tsx
Normal 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
35
exam/react/src/i18n.ts
Normal 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;
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 />}
|
||||
</>
|
||||
|
||||
@ -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 <></>;
|
||||
}
|
||||
|
||||
25
exam/react/src/utils/errors.ts
Normal file
25
exam/react/src/utils/errors.ts
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user