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" />
|
<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
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": {
|
"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",
|
||||||
|
|||||||
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!!');
|
throw new Error('New instance cannot be created!!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
instance = this;
|
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 { 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();
|
||||||
|
|
||||||
|
|||||||
@ -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,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
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