ErrorComponent, 404Component, Theming
This commit is contained in:
parent
e6fcd54e22
commit
bf2f4954ee
5
exam/dist/assets/index-BaG4uuqL.js
vendored
5
exam/dist/assets/index-BaG4uuqL.js
vendored
File diff suppressed because one or more lines are too long
5
exam/dist/assets/index-C9ueyNrZ.js
vendored
Normal file
5
exam/dist/assets/index-C9ueyNrZ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
exam/dist/index.html
vendored
6
exam/dist/index.html
vendored
@ -5,10 +5,10 @@
|
|||||||
<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-BaG4uuqL.js"></script>
|
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-C9ueyNrZ.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C_FdcE2X.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C_FdcE2X.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-aBip8mmu.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-4z7lf7t1.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-DojtBDN6.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-Do102PZ-.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DAXCIHlT.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DAXCIHlT.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-W-kxdzA-.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-W-kxdzA-.js">
|
||||||
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
||||||
|
|||||||
11
exam/dist/locales/de/translation.json
vendored
11
exam/dist/locales/de/translation.json
vendored
@ -86,5 +86,14 @@
|
|||||||
|
|
||||||
"Dark": "Dunkel",
|
"Dark": "Dunkel",
|
||||||
"Light": "Hell",
|
"Light": "Hell",
|
||||||
"System": "System"
|
"System": "System",
|
||||||
|
|
||||||
|
"Confirm success header": "Benutzer aktiviert!",
|
||||||
|
"Confirm error header": "Benutzer existiert nicht oder ist schon aktiviert.",
|
||||||
|
"Confirm pending header": "Benutzer wird aktiviert...",
|
||||||
|
"Back to main": "Zurück zur Hauptseite",
|
||||||
|
|
||||||
|
"Page not found": "Die Seite konnte nicht gefunden werden.",
|
||||||
|
"Session expired": "Deine Sitzung ist abgelaufen.",
|
||||||
|
"General error": "Da ist wohl was schief gelaufen."
|
||||||
}
|
}
|
||||||
|
|||||||
11
exam/dist/locales/en/translation.json
vendored
11
exam/dist/locales/en/translation.json
vendored
@ -87,5 +87,14 @@
|
|||||||
|
|
||||||
"Dark": "Dark",
|
"Dark": "Dark",
|
||||||
"Light": "Light",
|
"Light": "Light",
|
||||||
"System": "System"
|
"System": "System",
|
||||||
|
|
||||||
|
"Confirm success header": "User confirmed!",
|
||||||
|
"Confirm error header": "User does not exist or is already confirmed.",
|
||||||
|
"confirm pending header": "User is getting confirmed...",
|
||||||
|
"Back to main": "Back to the front page",
|
||||||
|
|
||||||
|
"Page not found": "This page was not found",
|
||||||
|
"Session expired": "Your session has expired.",
|
||||||
|
"General error": "Looks like something went wrong."
|
||||||
}
|
}
|
||||||
|
|||||||
2
exam/dist/stats.html
vendored
2
exam/dist/stats.html
vendored
File diff suppressed because one or more lines are too long
@ -86,5 +86,14 @@
|
|||||||
|
|
||||||
"Dark": "Dunkel",
|
"Dark": "Dunkel",
|
||||||
"Light": "Hell",
|
"Light": "Hell",
|
||||||
"System": "System"
|
"System": "System",
|
||||||
|
|
||||||
|
"Confirm success header": "Benutzer aktiviert!",
|
||||||
|
"Confirm error header": "Benutzer existiert nicht oder ist schon aktiviert.",
|
||||||
|
"Confirm pending header": "Benutzer wird aktiviert...",
|
||||||
|
"Back to main": "Zurück zur Hauptseite",
|
||||||
|
|
||||||
|
"Page not found": "Die Seite konnte nicht gefunden werden.",
|
||||||
|
"Session expired": "Deine Sitzung ist abgelaufen.",
|
||||||
|
"General error": "Da ist wohl was schief gelaufen."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,5 +87,14 @@
|
|||||||
|
|
||||||
"Dark": "Dark",
|
"Dark": "Dark",
|
||||||
"Light": "Light",
|
"Light": "Light",
|
||||||
"System": "System"
|
"System": "System",
|
||||||
|
|
||||||
|
"Confirm success header": "User confirmed!",
|
||||||
|
"Confirm error header": "User does not exist or is already confirmed.",
|
||||||
|
"confirm pending header": "User is getting confirmed...",
|
||||||
|
"Back to main": "Back to the front page",
|
||||||
|
|
||||||
|
"Page not found": "This page was not found",
|
||||||
|
"Session expired": "Your session has expired.",
|
||||||
|
"General error": "Looks like something went wrong."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ interface ApiContext {
|
|||||||
|
|
||||||
user?: (id?: number) => Promise<User>;
|
user?: (id?: number) => Promise<User>;
|
||||||
createUser?: (data: UserCreate) => Promise<User>;
|
createUser?: (data: UserCreate) => Promise<User>;
|
||||||
|
confirmUser?: (code: string) => Promise<User>;
|
||||||
updateUser?: (data: UserUpdate, id?: number) => Promise<User>;
|
updateUser?: (data: UserUpdate, id?: number) => Promise<User>;
|
||||||
updateUserImage?: (data: UserImageUpdate, id?: number) => Promise<User>;
|
updateUserImage?: (data: UserImageUpdate, id?: number) => Promise<User>;
|
||||||
|
|
||||||
@ -48,6 +49,7 @@ export const useApi = () => {
|
|||||||
|
|
||||||
user,
|
user,
|
||||||
createUser,
|
createUser,
|
||||||
|
confirmUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserImage,
|
updateUserImage,
|
||||||
|
|
||||||
@ -63,6 +65,7 @@ export const useApi = () => {
|
|||||||
deletePost &&
|
deletePost &&
|
||||||
user &&
|
user &&
|
||||||
createUser &&
|
createUser &&
|
||||||
|
confirmUser &&
|
||||||
updateUser &&
|
updateUser &&
|
||||||
updateUserImage &&
|
updateUserImage &&
|
||||||
userPosts
|
userPosts
|
||||||
@ -81,6 +84,7 @@ export const useApi = () => {
|
|||||||
|
|
||||||
user,
|
user,
|
||||||
createUser,
|
createUser,
|
||||||
|
confirmUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserImage,
|
updateUserImage,
|
||||||
|
|
||||||
@ -146,7 +150,7 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updatePost = async (data: PostUpdate, id: number): Promise<PostAuth> => {
|
const updatePost = async (data: PostUpdate, id: number): Promise<PostAuth> => {
|
||||||
return await (await reAuth(() => patch(`posts/${id}`, data as Record<string, unknown>))).json();
|
return await (await reAuth(() => patchAuth(`posts/${id}`, data as Record<string, unknown>))).json();
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletePost = async (id: number): Promise<PostDelete> => {
|
const deletePost = async (id: number): Promise<PostDelete> => {
|
||||||
@ -161,8 +165,14 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
return await (await post(`register`, data as unknown as Record<string, unknown>)).json();
|
return await (await post(`register`, data as unknown as Record<string, unknown>)).json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmUser = async (code: string): Promise<User> => {
|
||||||
|
return await (await patch(`register`, { code })).json();
|
||||||
|
};
|
||||||
|
|
||||||
const updateUser = async (data: UserUpdate, id?: number): Promise<User> => {
|
const updateUser = async (data: UserUpdate, id?: number): Promise<User> => {
|
||||||
const _user = await (await reAuth(() => patch(`users/${id ?? 'self'}`, data as Record<string, unknown>))).json();
|
const _user = await (
|
||||||
|
await reAuth(() => patchAuth(`users/${id ?? 'self'}`, data as Record<string, unknown>))
|
||||||
|
).json();
|
||||||
setAuthenticatedUser(_user);
|
setAuthenticatedUser(_user);
|
||||||
return _user;
|
return _user;
|
||||||
};
|
};
|
||||||
@ -247,6 +257,17 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
};
|
};
|
||||||
|
|
||||||
const patch = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
const patch = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
||||||
|
const response = await fetch(`${BASE}${endpoint}`, {
|
||||||
|
mode: 'cors',
|
||||||
|
method: 'patch',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (response.ok) return response;
|
||||||
|
throw await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchAuth = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
||||||
const response = await fetch(`${BASE}${endpoint}`, {
|
const response = await fetch(`${BASE}${endpoint}`, {
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
method: 'patch',
|
method: 'patch',
|
||||||
@ -313,6 +334,7 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
|
|
||||||
user,
|
user,
|
||||||
createUser,
|
createUser,
|
||||||
|
confirmUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserImage,
|
updateUserImage,
|
||||||
|
|
||||||
|
|||||||
52
exam/react/src/components/Error/ErrorRouterComponent.tsx
Normal file
52
exam/react/src/components/Error/ErrorRouterComponent.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { ErrorOutline } from '@mui/icons-material';
|
||||||
|
import { Grid, Link as MUILink, Typography } from '@mui/material';
|
||||||
|
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
|
||||||
|
import { ErrorRouteComponent as TSErrorRouteComponent, useNavigate, useRouter } from '@tanstack/react-router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import HeaderlessLayout from '../../components/Layouts/HeaderlessLayout';
|
||||||
|
import { ROUTES } from '../../types/Routes';
|
||||||
|
import { ERRORS } from './Errors';
|
||||||
|
|
||||||
|
const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryErrorResetBoundary = useQueryErrorResetBoundary();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset the query error boundary
|
||||||
|
console.log(queryErrorResetBoundary.isReset());
|
||||||
|
queryErrorResetBoundary.reset();
|
||||||
|
}, [queryErrorResetBoundary]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderlessLayout>
|
||||||
|
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<ErrorOutline sx={{ fontSize: '200px' }} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Typography variant="h4" sx={{ textAlign: 'center' }}>
|
||||||
|
{t('code' in error && error.code === ERRORS.UNAUTHORIZED ? 'Session expired' : 'General error')}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<MUILink
|
||||||
|
variant="h6"
|
||||||
|
underline="none"
|
||||||
|
onClick={() => {
|
||||||
|
console.log('CLICK AS WELL');
|
||||||
|
router.invalidate();
|
||||||
|
navigate({ to: ROUTES.INDEX });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Back to main')}
|
||||||
|
</MUILink>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</HeaderlessLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorRouterComponent;
|
||||||
20
exam/react/src/components/Layouts/HeaderLayout.tsx
Normal file
20
exam/react/src/components/Layouts/HeaderLayout.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { ReactNode } from '@tanstack/react-router';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import Footer from '../Footer/Footer';
|
||||||
|
import Header from '../Header/Header';
|
||||||
|
|
||||||
|
const HeaderLayout: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Box sx={{ maxWidth: '800px', flexGrow: 1 }}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderLayout;
|
||||||
18
exam/react/src/components/Layouts/HeaderlessLayout.tsx
Normal file
18
exam/react/src/components/Layouts/HeaderlessLayout.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
import Footer from '../Footer/Footer';
|
||||||
|
|
||||||
|
const HeaderlessLayout: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Box sx={{ maxWidth: '800px', flexGrow: 1 }}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderlessLayout;
|
||||||
31
exam/react/src/components/NotFound/NotFoundComponent.tsx
Normal file
31
exam/react/src/components/NotFound/NotFoundComponent.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Grid, Link as MUILink, Typography } from '@mui/material';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import HeaderlessLayout from '../../components/Layouts/HeaderlessLayout';
|
||||||
|
|
||||||
|
const NotFoundComponent: FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderlessLayout>
|
||||||
|
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Typography variant="404">404</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Typography variant="h4" sx={{ textAlign: 'center' }}>
|
||||||
|
{t('Page not found')}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<MUILink component={Link} to="/" variant="h6" underline="none">
|
||||||
|
{t('Back to main')}
|
||||||
|
</MUILink>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</HeaderlessLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFoundComponent;
|
||||||
@ -3,18 +3,15 @@ import { useApi } from '../api/Api';
|
|||||||
|
|
||||||
export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile', { id: Api.authenticatedUser?.id }],
|
||||||
queryFn: async () => await Api.user(),
|
queryFn: async () => ({
|
||||||
|
user: await Api.user(),
|
||||||
|
posts: await Api.userPosts(Api.authenticatedUser?.id ?? 0),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileQueryOptions = (Api: ReturnType<typeof useApi>, id?: number) =>
|
export const profileQueryOptions = (Api: ReturnType<typeof useApi>, id?: number) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['profile', { id }],
|
queryKey: ['profile', { id }],
|
||||||
queryFn: async () => await Api.user(id),
|
queryFn: async () => ({ user: await Api.user(id), posts: await Api.userPosts(id) }),
|
||||||
});
|
|
||||||
|
|
||||||
export const profilePostsQueryOptions = (Api: ReturnType<typeof useApi>, id: number) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ['profilePosts', { id }],
|
|
||||||
queryFn: async () => await Api.userPosts(id),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
import { Route as IndexImport } from './routes/index'
|
import { Route as IndexImport } from './routes/index'
|
||||||
import { Route as ProfileIndexImport } from './routes/profile/index'
|
import { Route as ProfileIndexImport } from './routes/profile/index'
|
||||||
|
import { Route as ConfirmIndexImport } from './routes/confirm/index'
|
||||||
import { Route as ProfileIdImport } from './routes/profile/$id'
|
import { Route as ProfileIdImport } from './routes/profile/$id'
|
||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
@ -27,6 +28,11 @@ const ProfileIndexRoute = ProfileIndexImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const ConfirmIndexRoute = ConfirmIndexImport.update({
|
||||||
|
path: '/confirm/',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const ProfileIdRoute = ProfileIdImport.update({
|
const ProfileIdRoute = ProfileIdImport.update({
|
||||||
path: '/profile/$id',
|
path: '/profile/$id',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@ -50,6 +56,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProfileIdImport
|
preLoaderRoute: typeof ProfileIdImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/confirm/': {
|
||||||
|
id: '/confirm/'
|
||||||
|
path: '/confirm'
|
||||||
|
fullPath: '/confirm'
|
||||||
|
preLoaderRoute: typeof ConfirmIndexImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
'/profile/': {
|
'/profile/': {
|
||||||
id: '/profile/'
|
id: '/profile/'
|
||||||
path: '/profile'
|
path: '/profile'
|
||||||
@ -65,6 +78,7 @@ declare module '@tanstack/react-router' {
|
|||||||
export const routeTree = rootRoute.addChildren({
|
export const routeTree = rootRoute.addChildren({
|
||||||
IndexRoute,
|
IndexRoute,
|
||||||
ProfileIdRoute,
|
ProfileIdRoute,
|
||||||
|
ConfirmIndexRoute,
|
||||||
ProfileIndexRoute,
|
ProfileIndexRoute,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -78,6 +92,7 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
"/profile/$id",
|
"/profile/$id",
|
||||||
|
"/confirm/",
|
||||||
"/profile/"
|
"/profile/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -87,6 +102,9 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
"/profile/$id": {
|
"/profile/$id": {
|
||||||
"filePath": "profile/$id.tsx"
|
"filePath": "profile/$id.tsx"
|
||||||
},
|
},
|
||||||
|
"/confirm/": {
|
||||||
|
"filePath": "confirm/index.tsx"
|
||||||
|
},
|
||||||
"/profile/": {
|
"/profile/": {
|
||||||
"filePath": "profile/index.tsx"
|
"filePath": "profile/index.tsx"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +1,22 @@
|
|||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import { createRootRouteWithContext, ErrorRouteComponent, Outlet, useRouter } from '@tanstack/react-router';
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
|
||||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useApi } from '../api/Api';
|
import { useApi } from '../api/Api';
|
||||||
import Footer from '../components/Footer/Footer';
|
import ErrorRouterComponent from '../components/Error/ErrorRouterComponent';
|
||||||
import Header from '../components/Header/Header';
|
import NotFoundComponent from '../components/NotFound/NotFoundComponent';
|
||||||
|
|
||||||
const Root = () => {
|
const Root = () => {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
<Header />
|
<Outlet />
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Box sx={{ maxWidth: '800px', flexGrow: 1 }}>
|
|
||||||
<Outlet />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
|
||||||
<Footer />
|
|
||||||
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
|
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ErrorComponent: ErrorRouteComponent = ({ error }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const queryErrorResetBoundary = useQueryErrorResetBoundary();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset the query error boundary
|
|
||||||
queryErrorResetBoundary.reset();
|
|
||||||
}, [queryErrorResetBoundary]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{error.message}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Invalidate the route to reload the loader, and reset any router error boundaries
|
|
||||||
router.invalidate();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({
|
||||||
component: Root,
|
component: Root,
|
||||||
errorComponent: ErrorComponent,
|
notFoundComponent: NotFoundComponent,
|
||||||
|
errorComponent: ErrorRouterComponent,
|
||||||
});
|
});
|
||||||
|
|||||||
71
exam/react/src/routes/confirm/index.tsx
Normal file
71
exam/react/src/routes/confirm/index.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Done, Error } from '@mui/icons-material';
|
||||||
|
import { CircularProgress, Grid, Link as MUILink, Typography } from '@mui/material';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useApi } from '../../api/Api';
|
||||||
|
import HeaderlessLayout from '../../components/Layouts/HeaderlessLayout';
|
||||||
|
import { ROUTES } from '../../types/Routes';
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const Api = useApi();
|
||||||
|
const { code } = Route.useSearch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const confirmMutation = useMutation({
|
||||||
|
mutationFn: ({ code: _code }: { code: string }) => {
|
||||||
|
return Api.confirmUser(_code);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) setTimeout(() => confirmMutation.mutate({ code }), 1000);
|
||||||
|
}, []); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code) {
|
||||||
|
navigate({ to: '/' });
|
||||||
|
}
|
||||||
|
}, [code]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderlessLayout>
|
||||||
|
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{confirmMutation.isSuccess && <Done color="action" sx={{ fontSize: '200px' }} />}
|
||||||
|
{confirmMutation.isError && <Error color="action" sx={{ fontSize: '200px' }} />}
|
||||||
|
{(confirmMutation.isPending || confirmMutation.isIdle) && <CircularProgress size={200} />}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{confirmMutation.isSuccess && <Typography variant="h5">{t('Confirm success header')}</Typography>}
|
||||||
|
{confirmMutation.isError && (
|
||||||
|
<Typography variant="h5" color="error">
|
||||||
|
{t('Confirm error header')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{(confirmMutation.isPending || confirmMutation.isIdle) && (
|
||||||
|
<Typography variant="h5">{t('Confirm pending header')}</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{!confirmMutation.isPending && !confirmMutation.isIdle && (
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<MUILink component={Link} to="/" variant="h6" underline="none">
|
||||||
|
{t('Back to main')}
|
||||||
|
</MUILink>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</HeaderlessLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute(`${ROUTES.CONFIRM}/`)({
|
||||||
|
validateSearch: (search: Record<string, unknown>): { code?: string } => {
|
||||||
|
return {
|
||||||
|
code: search?.code !== undefined ? (search?.code as string) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Home,
|
||||||
|
});
|
||||||
@ -14,6 +14,7 @@ import { useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useApi } from '../api/Api';
|
import { useApi } from '../api/Api';
|
||||||
import PostForm from '../components/Forms/Post/PostForm';
|
import PostForm from '../components/Forms/Post/PostForm';
|
||||||
|
import HeaderLayout from '../components/Layouts/HeaderLayout';
|
||||||
import Post from '../components/Post/Post';
|
import Post from '../components/Post/Post';
|
||||||
import { postsQueryOptions } from '../queries/postsQuery';
|
import { postsQueryOptions } from '../queries/postsQuery';
|
||||||
import { ROUTES } from '../types/Routes';
|
import { ROUTES } from '../types/Routes';
|
||||||
@ -34,7 +35,7 @@ const Home = () => {
|
|||||||
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
|
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HeaderLayout>
|
||||||
<Snackbar open={isFetching} message={t('Updating')} />
|
<Snackbar open={isFetching} message={t('Updating')} />
|
||||||
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||||
{postsQuery.data.map((post) => (
|
{postsQuery.data.map((post) => (
|
||||||
@ -74,7 +75,7 @@ const Home = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</HeaderLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import { createFileRoute, redirect } from '@tanstack/react-router';
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { useApi } from '../../api/Api';
|
import { useApi } from '../../api/Api';
|
||||||
import { ERRORS } from '../../components/Error/Errors';
|
import { ERRORS } from '../../components/Error/Errors';
|
||||||
|
import HeaderLayout from '../../components/Layouts/HeaderLayout';
|
||||||
import Profile from '../../components/Profile/Profile';
|
import Profile from '../../components/Profile/Profile';
|
||||||
import { profilePostsQueryOptions, profileQueryOptions } from '../../queries/profileQuery';
|
import { profileQueryOptions } from '../../queries/profileQuery';
|
||||||
import { PostAuth } from '../../types/Post';
|
import { PostAuth } from '../../types/Post';
|
||||||
import { ROUTES } from '../../types/Routes';
|
import { ROUTES } from '../../types/Routes';
|
||||||
|
|
||||||
@ -13,40 +14,24 @@ const ProfilePage = () => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const {
|
const {
|
||||||
data: profileQuery,
|
data: { user, posts },
|
||||||
isFetching: isFetchingProfile,
|
isFetching,
|
||||||
error: errorProfile,
|
error,
|
||||||
failureReason: failureReasonProfile,
|
failureReason,
|
||||||
} = useSuspenseQuery(profileQueryOptions(Api));
|
} = useSuspenseQuery(profileQueryOptions(Api, id));
|
||||||
const {
|
|
||||||
data: profilePostsQuery,
|
|
||||||
isFetching: isFetchingPosts,
|
|
||||||
error: errorPosts,
|
|
||||||
failureReason: failureReasonPosts,
|
|
||||||
} = useSuspenseQuery(profilePostsQueryOptions(Api, id));
|
|
||||||
|
|
||||||
if (failureReasonProfile && 'code' in failureReasonProfile && failureReasonProfile.code === ERRORS.UNAUTHORIZED) {
|
if (failureReason && 'code' in failureReason && failureReason.code === ERRORS.UNAUTHORIZED) {
|
||||||
throw failureReasonProfile;
|
throw failureReason;
|
||||||
}
|
}
|
||||||
if (errorProfile && 'code' in errorProfile && errorProfile.code === ERRORS.UNAUTHORIZED) {
|
if (error && 'code' in error && error.code === ERRORS.UNAUTHORIZED) {
|
||||||
throw errorProfile;
|
throw error;
|
||||||
}
|
|
||||||
if (failureReasonPosts && 'code' in failureReasonPosts && failureReasonPosts.code === ERRORS.UNAUTHORIZED) {
|
|
||||||
throw failureReasonPosts;
|
|
||||||
}
|
|
||||||
if (errorPosts && 'code' in errorPosts && errorPosts.code === ERRORS.UNAUTHORIZED) {
|
|
||||||
throw errorPosts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HeaderLayout>
|
||||||
<Snackbar open={isFetchingProfile || isFetchingPosts} message={t('Updating')} />
|
<Snackbar open={isFetching} message={t('Updating')} />
|
||||||
<Profile
|
<Profile user={user} posts={posts.data as PostAuth[]} canEdit={Api.authenticatedUser?.isAdmin} />
|
||||||
user={profileQuery}
|
</HeaderLayout>
|
||||||
posts={profilePostsQuery.data as PostAuth[]}
|
|
||||||
canEdit={Api.authenticatedUser?.isAdmin}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +42,6 @@ export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
|
|||||||
},
|
},
|
||||||
loader: ({ context: { queryClient, Api }, params: { id } }) => {
|
loader: ({ context: { queryClient, Api }, params: { id } }) => {
|
||||||
queryClient.ensureQueryData(profileQueryOptions(Api, id));
|
queryClient.ensureQueryData(profileQueryOptions(Api, id));
|
||||||
queryClient.ensureQueryData(profilePostsQueryOptions(Api, id));
|
|
||||||
},
|
},
|
||||||
beforeLoad: ({ params: { id }, context: { Api } }) => {
|
beforeLoad: ({ params: { id }, context: { Api } }) => {
|
||||||
if (!Api.hasAuth) throw redirect({ to: ROUTES.INDEX });
|
if (!Api.hasAuth) throw redirect({ to: ROUTES.INDEX });
|
||||||
|
|||||||
@ -4,51 +4,39 @@ import { createFileRoute, redirect } from '@tanstack/react-router';
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { useApi } from '../../api/Api';
|
import { useApi } from '../../api/Api';
|
||||||
import { ERRORS } from '../../components/Error/Errors';
|
import { ERRORS } from '../../components/Error/Errors';
|
||||||
|
import HeaderLayout from '../../components/Layouts/HeaderLayout';
|
||||||
import Profile from '../../components/Profile/Profile';
|
import Profile from '../../components/Profile/Profile';
|
||||||
import { profilePostsQueryOptions, profileSelfQueryOptions } from '../../queries/profileQuery';
|
import { profileSelfQueryOptions } from '../../queries/profileQuery';
|
||||||
import { PostAuth } from '../../types/Post';
|
import { PostAuth } from '../../types/Post';
|
||||||
import { ROUTES } from '../../types/Routes';
|
import { ROUTES } from '../../types/Routes';
|
||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
const {
|
const {
|
||||||
data: profileQuery,
|
data: { user, posts },
|
||||||
isFetching: isFetchingProfile,
|
isFetching,
|
||||||
error: errorProfile,
|
error,
|
||||||
failureReason: failureReasonProfile,
|
failureReason,
|
||||||
} = useSuspenseQuery(profileSelfQueryOptions(Api));
|
} = useSuspenseQuery(profileSelfQueryOptions(Api));
|
||||||
const {
|
|
||||||
data: profilePostsQuery,
|
|
||||||
isFetching: isFetchingPosts,
|
|
||||||
error: errorPosts,
|
|
||||||
failureReason: failureReasonPosts,
|
|
||||||
} = useSuspenseQuery(profilePostsQueryOptions(Api, Api.authenticatedUser?.id ?? 0));
|
|
||||||
|
|
||||||
if (failureReasonProfile && 'code' in failureReasonProfile && failureReasonProfile.code === ERRORS.UNAUTHORIZED) {
|
if (failureReason && 'code' in failureReason && failureReason.code === ERRORS.UNAUTHORIZED) {
|
||||||
throw failureReasonProfile;
|
throw failureReason;
|
||||||
}
|
}
|
||||||
if (errorProfile && 'code' in errorProfile && errorProfile.code === ERRORS.UNAUTHORIZED) {
|
if (error && 'code' in error && error.code === ERRORS.UNAUTHORIZED) {
|
||||||
throw errorProfile;
|
throw error;
|
||||||
}
|
|
||||||
if (failureReasonPosts && 'code' in failureReasonPosts && failureReasonPosts.code === ERRORS.UNAUTHORIZED) {
|
|
||||||
throw failureReasonPosts;
|
|
||||||
}
|
|
||||||
if (errorPosts && 'code' in errorPosts && errorPosts.code === ERRORS.UNAUTHORIZED) {
|
|
||||||
throw errorPosts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HeaderLayout>
|
||||||
<Snackbar open={isFetchingProfile || isFetchingPosts} message={t('Updating')} />
|
<Snackbar open={isFetching} message={t('Updating')} />
|
||||||
<Profile user={profileQuery} posts={profilePostsQuery.data as PostAuth[]} canEdit={true} />
|
<Profile user={user} posts={posts.data as PostAuth[]} canEdit={true} />
|
||||||
</>
|
</HeaderLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
|
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
|
||||||
loader: ({ context: { queryClient, Api } }) => {
|
loader: ({ context: { queryClient, Api } }) => {
|
||||||
queryClient.ensureQueryData(profileSelfQueryOptions(Api));
|
queryClient.ensureQueryData(profileSelfQueryOptions(Api));
|
||||||
queryClient.ensureQueryData(profilePostsQueryOptions(Api, Api.authenticatedUser?.id ?? 0));
|
|
||||||
},
|
},
|
||||||
beforeLoad: ({ context: { Api } }) => {
|
beforeLoad: ({ context: { Api } }) => {
|
||||||
if (!Api.hasAuth) throw redirect({ to: ROUTES.INDEX });
|
if (!Api.hasAuth) throw redirect({ to: ROUTES.INDEX });
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { createTheme } from '@mui/material';
|
import { createTheme } from '@mui/material';
|
||||||
|
import typography from '../overrides/typography';
|
||||||
|
import MuiSnackbarContent from '../overrides/MuiSnackbarContent';
|
||||||
|
|
||||||
const darkTheme = createTheme({
|
let darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'dark',
|
mode: 'dark',
|
||||||
primary: {
|
primary: {
|
||||||
@ -26,15 +28,14 @@ const darkTheme = createTheme({
|
|||||||
paper: '#0d1019',
|
paper: '#0d1019',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
darkTheme = createTheme(darkTheme, {
|
||||||
|
typography: {
|
||||||
|
...typography(darkTheme),
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
MuiSnackbarContent: {
|
...MuiSnackbarContent,
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: undefined,
|
|
||||||
color: 'text.primary',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { createTheme } from '@mui/material';
|
import { createTheme } from '@mui/material';
|
||||||
|
import typography from '../overrides/typography';
|
||||||
|
import MuiSnackbarContent from '../overrides/MuiSnackbarContent';
|
||||||
|
|
||||||
const lightTheme = createTheme({
|
let lightTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
primary: {
|
primary: {
|
||||||
@ -26,15 +28,14 @@ const lightTheme = createTheme({
|
|||||||
paper: '#ffffff',
|
paper: '#ffffff',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lightTheme = createTheme(lightTheme, {
|
||||||
|
typography: {
|
||||||
|
...typography(lightTheme),
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
MuiSnackbarContent: {
|
...MuiSnackbarContent,
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: undefined,
|
|
||||||
color: 'text.primary',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
exam/react/src/theme/overrides/MuiSnackbarContent.ts
Normal file
12
exam/react/src/theme/overrides/MuiSnackbarContent.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Components } from '@mui/material';
|
||||||
|
|
||||||
|
const MuiSnackbarContent: Components['MuiSnackbarContent'] = {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: undefined,
|
||||||
|
color: 'text.primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MuiSnackbarContent;
|
||||||
34
exam/react/src/theme/overrides/typography.ts
Normal file
34
exam/react/src/theme/overrides/typography.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Palette, Theme } from '@mui/material';
|
||||||
|
import { TypographyOptions } from '@mui/material/styles/createTypography';
|
||||||
|
|
||||||
|
declare module '@mui/material/styles' {
|
||||||
|
interface TypographyVariants {
|
||||||
|
'404': React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow configuration using `createTheme`
|
||||||
|
interface TypographyVariantsOptions {
|
||||||
|
'404'?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Typography's variant prop options
|
||||||
|
declare module '@mui/material/Typography' {
|
||||||
|
interface TypographyPropsVariantOverrides {
|
||||||
|
'404': true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typography = (theme: Theme): TypographyOptions | ((palette: Palette) => TypographyOptions) => ({
|
||||||
|
'404': {
|
||||||
|
fontSize: '6rem',
|
||||||
|
[theme.breakpoints.up('sm')]: {
|
||||||
|
fontSize: '12rem',
|
||||||
|
},
|
||||||
|
[theme.breakpoints.up('md')]: {
|
||||||
|
fontSize: '18rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default typography;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export enum ROUTES {
|
export enum ROUTES {
|
||||||
INDEX = '/',
|
INDEX = '/',
|
||||||
PROFILE = '/profile',
|
PROFILE = '/profile',
|
||||||
|
CONFIRM = '/confirm',
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user