Auth redirects, language switch, basic profile
This commit is contained in:
@@ -6,8 +6,9 @@ const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
|
||||
let instance: ApiImpl;
|
||||
|
||||
class ApiImpl {
|
||||
//FIXME: PRIVATE when reauth token exists
|
||||
public token?: string;
|
||||
private token?: string;
|
||||
private refreshToken?: string;
|
||||
private self?: User;
|
||||
|
||||
constructor() {
|
||||
if (instance) {
|
||||
@@ -19,18 +20,26 @@ class ApiImpl {
|
||||
}
|
||||
|
||||
public hasAuth = () => this.token !== undefined;
|
||||
public isAdmin = () => this.hasAuth() && this.self?.isAdmin;
|
||||
public getAuthenticatedUser = () => this.self;
|
||||
public getCurrentSession = () => [this.token, this.refreshToken];
|
||||
|
||||
//FIXME: Currently returns Auth token, switch to reauth token
|
||||
public logIn = async (email: string, password: string): Promise<[User, string]> => {
|
||||
public logIn = async (email: string, password: string): Promise<void> => {
|
||||
const { user, token } = await (await this.post('login', { email, password })).json();
|
||||
this.self = user;
|
||||
this.isAdmin = user.isAdmin;
|
||||
this.token = token;
|
||||
|
||||
return [user, token];
|
||||
};
|
||||
|
||||
public logOut = async (): Promise<boolean> => {
|
||||
this.token = undefined;
|
||||
return await (await this.postAuth('logout')).json();
|
||||
try {
|
||||
return await (await this.postAuth('logout')).json();
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
this.self = undefined;
|
||||
this.token = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
|
||||
@@ -41,6 +50,10 @@ class ApiImpl {
|
||||
return await (await this.get(url)).json();
|
||||
};
|
||||
|
||||
public user = async (id?: number): Promise<User> => {
|
||||
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
|
||||
};
|
||||
|
||||
private post = async (
|
||||
endpoint: string,
|
||||
body: Record<string, unknown> | undefined = undefined,
|
||||
|
||||
+8
-7
@@ -4,13 +4,14 @@ import { useRouter } from '@tanstack/react-router';
|
||||
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();
|
||||
interface Props {
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
const setUser = useGuestBookStore((state) => state.setUser);
|
||||
const LoginForm: FC<Props> = ({ handleClose }) => {
|
||||
const [error, setError] = useState();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -22,9 +23,9 @@ const Login: FC = () => {
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
const [user, token] = await Api.logIn(value.email, value.password);
|
||||
setUser(user, token);
|
||||
await Api.logIn(value.email, value.password);
|
||||
router.invalidate();
|
||||
handleClose();
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
setError(error);
|
||||
@@ -126,4 +127,4 @@ const Login: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
export default LoginForm;
|
||||
@@ -1,22 +1,20 @@
|
||||
import { AccountCircle } from '@mui/icons-material';
|
||||
import { AccountCircle, Translate } from '@mui/icons-material';
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Link as MUILink,
|
||||
Toolbar,
|
||||
useScrollTrigger,
|
||||
} from '@mui/material';
|
||||
import { Link, useRouter, useRouterState } from '@tanstack/react-router';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
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';
|
||||
import LanguageMenu from '../Menus/Language/LanguageMenu';
|
||||
import UserMenu from '../Menus/User/UserMenu';
|
||||
|
||||
const ElevationScroll = ({ children }: { children: ReactElement }) => {
|
||||
const trigger = useScrollTrigger({
|
||||
@@ -30,21 +28,17 @@ const ElevationScroll = ({ children }: { children: ReactElement }) => {
|
||||
};
|
||||
|
||||
const Header: FC = () => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const user = useGuestBookStore((state) => state.user);
|
||||
const setUser = useGuestBookStore((state) => state.setUser);
|
||||
const [anchorUserMenu, setAnchorUserMenu] = useState<null | HTMLElement>(null);
|
||||
const [anchorLanguageMenu, setAnchorLanguageMenu] = useState<null | HTMLElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const isLoading = useRouterState({ select: (s) => s.status === 'pending' });
|
||||
|
||||
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const user = Api.getAuthenticatedUser();
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
setAnchorLanguageMenu(null);
|
||||
setAnchorUserMenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -58,55 +52,20 @@ const Header: FC = () => {
|
||||
</MUILink>
|
||||
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
|
||||
</Box>
|
||||
<IconButton size="large" onClick={(event) => setAnchorLanguageMenu(event.currentTarget)}>
|
||||
<Translate sx={{ color: 'white' }} />
|
||||
</IconButton>
|
||||
{user ? (
|
||||
<IconButton onClick={handleMenu} sx={{ p: 0 }}>
|
||||
<IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}>
|
||||
<Avatar alt={user.username} src={`storage/${user.image}`} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton size="large" onClick={handleMenu} color="inherit">
|
||||
<IconButton size="large" onClick={(event) => setAnchorUserMenu(event.currentTarget)} 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}
|
||||
sx={{
|
||||
'& .MuiMenu-paper': {
|
||||
minWidth: '240px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{user ? (
|
||||
[
|
||||
<MenuItem key="profile" onClick={handleClose}>
|
||||
{t('Profile')}
|
||||
</MenuItem>,
|
||||
<MenuItem
|
||||
key="logout"
|
||||
onClick={() => {
|
||||
Api.logOut();
|
||||
setUser();
|
||||
router.invalidate();
|
||||
}}
|
||||
>
|
||||
{t('Log out')}
|
||||
</MenuItem>,
|
||||
]
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</Menu>
|
||||
<LanguageMenu anchorEl={anchorLanguageMenu} handleClose={handleClose} />
|
||||
<UserMenu anchorEl={anchorUserMenu} handleClose={handleClose} />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Toolbar />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Menu, MenuItem } from '@mui/material';
|
||||
import { FC } from 'react';
|
||||
import i18n from '../../../i18n';
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
const LanguageMenu: FC<Props> = ({ anchorEl, handleClose }) => {
|
||||
return (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
sx={{
|
||||
'& .MuiMenu-paper': {
|
||||
minWidth: '240px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
key="de"
|
||||
selected={i18n.language === 'en'}
|
||||
onClick={() => {
|
||||
i18n.changeLanguage('en');
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
key="en"
|
||||
selected={i18n.language === 'de'}
|
||||
onClick={() => {
|
||||
i18n.changeLanguage('de');
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
Deutsch
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageMenu;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Menu, MenuItem } from '@mui/material';
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router';
|
||||
import { t } from 'i18next';
|
||||
import { FC } from 'react';
|
||||
import Api from '../../../api/Api';
|
||||
import { ROUTES } from '../../../types/Routes';
|
||||
import LoginForm from '../../Forms/Login/LoginForm';
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
|
||||
const user = Api.getAuthenticatedUser();
|
||||
|
||||
return (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
sx={{
|
||||
'& .MuiMenu-paper': {
|
||||
minWidth: '240px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{user ? (
|
||||
[
|
||||
<MenuItem
|
||||
key="profile"
|
||||
onClick={() => {
|
||||
navigate({ to: ROUTES.PROFILE });
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
{t('Profile')}
|
||||
</MenuItem>,
|
||||
<MenuItem
|
||||
key="logout"
|
||||
onClick={() => {
|
||||
Api.logOut();
|
||||
router.invalidate();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
{t('Log out')}
|
||||
</MenuItem>,
|
||||
]
|
||||
) : (
|
||||
<LoginForm handleClose={handleClose} />
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Avatar, Card, CardContent, CardHeader, Link as MUILink, Typography } from '@mui/material';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { FC } from 'react';
|
||||
import Api from '../../api/Api';
|
||||
import { PostAuth, PostNonAuth } from '../../types/Post';
|
||||
import convertDate from '../../utils/date';
|
||||
|
||||
interface Props {
|
||||
post: PostNonAuth | PostAuth;
|
||||
@@ -12,21 +14,37 @@ const Post: FC<Props> = ({ post }) => {
|
||||
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<MUILink component={Link} to="/" color="#FFF" variant="h6" underline="none">
|
||||
'id' in post.user ? (
|
||||
<MUILink
|
||||
component={Link}
|
||||
to="/profile/$id"
|
||||
params={{ id: post.user.id }}
|
||||
color="#FFF"
|
||||
variant="h6"
|
||||
underline="none"
|
||||
>
|
||||
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
|
||||
</MUILink>
|
||||
) : (
|
||||
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
|
||||
</MUILink>
|
||||
)
|
||||
}
|
||||
title={post.user.username}
|
||||
subheader={new Date(post.postedAt.date).toLocaleString(navigator.languages[0] ?? 'de-DE', {
|
||||
timeZone: post.postedAt.timezone,
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
minute: '2-digit',
|
||||
})}
|
||||
title={
|
||||
'id' in post.user ? (
|
||||
post.user.id !== Api.getAuthenticatedUser()?.id ? (
|
||||
<MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }}>
|
||||
{post.user.username}
|
||||
</MUILink>
|
||||
) : (
|
||||
<MUILink component={Link} to="/profile">
|
||||
{post.user.username}
|
||||
</MUILink>
|
||||
)
|
||||
) : (
|
||||
post.user.username
|
||||
)
|
||||
}
|
||||
subheader={convertDate(post.postedAt)}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography>{post.content}</Typography>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Avatar, Box, Grid, Typography } from '@mui/material';
|
||||
import { FC } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User } from '../../types/User';
|
||||
import convertDate from '../../utils/date';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const Profile: FC<Props> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item sx={{ display: 'grid', gridTemplateColumns: 'fit-content(100%) 1fr', columnGap: 2, rowGap: 1 }}>
|
||||
<Box sx={{ gridColumn: '1/3', display: 'flex', justifyContent: 'center' }}>
|
||||
<Avatar alt={user.username} src={`storage/${user.image}`} sx={{ width: 100, height: 100 }} />
|
||||
</Box>
|
||||
<Typography fontWeight="bold">{t('Username')}:</Typography>
|
||||
<Typography>{user.username}</Typography>
|
||||
<Typography fontWeight="bold">{t('Member since')}:</Typography>
|
||||
<Typography>{convertDate(user.memberSince)}</Typography>
|
||||
<Typography fontWeight="bold">{t('Post count')}:</Typography>
|
||||
<Typography>{user.postCount}</Typography>
|
||||
</Grid>
|
||||
<Grid item></Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as ProfileIndexImport } from './routes/profile/index'
|
||||
import { Route as ProfileIdImport } from './routes/profile/$id'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
@@ -20,6 +22,16 @@ const IndexRoute = IndexImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ProfileIndexRoute = ProfileIndexImport.update({
|
||||
path: '/profile/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ProfileIdRoute = ProfileIdImport.update({
|
||||
path: '/profile/$id',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -31,12 +43,30 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/profile/$id': {
|
||||
id: '/profile/$id'
|
||||
path: '/profile/$id'
|
||||
fullPath: '/profile/$id'
|
||||
preLoaderRoute: typeof ProfileIdImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/profile/': {
|
||||
id: '/profile/'
|
||||
path: '/profile'
|
||||
fullPath: '/profile'
|
||||
preLoaderRoute: typeof ProfileIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren({ IndexRoute })
|
||||
export const routeTree = rootRoute.addChildren({
|
||||
IndexRoute,
|
||||
ProfileIdRoute,
|
||||
ProfileIndexRoute,
|
||||
})
|
||||
|
||||
/* prettier-ignore-end */
|
||||
|
||||
@@ -46,11 +76,19 @@ export const routeTree = rootRoute.addChildren({ IndexRoute })
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/"
|
||||
"/",
|
||||
"/profile/$id",
|
||||
"/profile/"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/profile/$id": {
|
||||
"filePath": "profile/$id.tsx"
|
||||
},
|
||||
"/profile/": {
|
||||
"filePath": "profile/index.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
import { Toolbar } from '@mui/material';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
|
||||
import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
|
||||
import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router';
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||
import { useEffect } from 'react';
|
||||
import Api from '../api/Api';
|
||||
import Header from '../components/Header/Header';
|
||||
import useGuestBookStore from '../store/store';
|
||||
import { ROUTES } from '../types/Routes';
|
||||
import { ERRORS } from '../utils/errors';
|
||||
|
||||
const Root = () => {
|
||||
//FIXME: REAUTH HERE
|
||||
const token = useGuestBookStore((state) => state.token);
|
||||
Api.token = token;
|
||||
//TODO: REAUTH HERE
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
//TODO: Make nice
|
||||
const Error: ErrorRouteComponent = ({ error }) => {
|
||||
const router = useRouter();
|
||||
const queryErrorResetBoundary = useQueryErrorResetBoundary();
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the query error boundary
|
||||
queryErrorResetBoundary.reset();
|
||||
}, [queryErrorResetBoundary]);
|
||||
|
||||
if ('code' in error && error.code === ERRORS.UNAUTHORIZED) {
|
||||
Api.logOut();
|
||||
redirect({ to: ROUTES.INDEX });
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{error.message}
|
||||
<button
|
||||
onClick={() => {
|
||||
router.invalidate();
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||
component: Root,
|
||||
errorComponent: Error,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
|
||||
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Api from '../api/Api';
|
||||
import Post from '../components/Post/Post';
|
||||
import { ROUTES } from '../types/Routes';
|
||||
|
||||
const postsQueryOptions = (page?: number) =>
|
||||
queryOptions({
|
||||
@@ -10,28 +12,18 @@ const postsQueryOptions = (page?: number) =>
|
||||
queryFn: () => Api.posts(page),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
loaderDeps: ({ search: { page } }) => ({ page }),
|
||||
loader: ({ context: { queryClient }, deps: { page } }) => queryClient.ensureQueryData(postsQueryOptions(page)),
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||
return {
|
||||
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
|
||||
};
|
||||
},
|
||||
component: Home,
|
||||
});
|
||||
|
||||
function Home() {
|
||||
const Home = () => {
|
||||
const { page } = Route.useSearch();
|
||||
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar open={isFetching} message="Updating" />
|
||||
<Snackbar open={isFetching} message={t('Updating')} />
|
||||
<Grid container spacing={2}>
|
||||
{postsQuery.data.map((post) => (
|
||||
<Grid item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
|
||||
<Post key={post.id} post={post} />
|
||||
<Grid key={post.id} item xs={12} md={6} lg={4} sx={{ display: 'flex' }}>
|
||||
<Post post={post} />
|
||||
</Grid>
|
||||
))}
|
||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -44,7 +36,7 @@ function Home() {
|
||||
{...item}
|
||||
component={Link}
|
||||
to="/"
|
||||
search={{ page: item.page ? item.page - 1 : undefined }}
|
||||
search={{ page: (item.page ?? 0) > 0 ? (item.page ?? 1) - 1 : undefined }}
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={(e) => item.onClick(e as any)}
|
||||
/>
|
||||
@@ -54,4 +46,15 @@ function Home() {
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(ROUTES.INDEX)({
|
||||
loaderDeps: ({ search: { page } }) => ({ page }),
|
||||
loader: ({ context: { queryClient }, deps: { page } }) => queryClient.ensureQueryData(postsQueryOptions(page)),
|
||||
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||
return {
|
||||
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
|
||||
};
|
||||
},
|
||||
component: Home,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Snackbar } from '@mui/material';
|
||||
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { t } from 'i18next';
|
||||
import Api from '../../api/Api';
|
||||
import Profile from '../../components/Profile/Profile';
|
||||
import { ROUTES } from '../../types/Routes';
|
||||
|
||||
const profileQueryOptions = (id?: number) =>
|
||||
queryOptions({
|
||||
queryKey: ['profile', { id }],
|
||||
queryFn: () => Api.user(id),
|
||||
});
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { id } = Route.useParams();
|
||||
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar open={isFetching} message={t('Updating')} />
|
||||
<Profile user={profileQuery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
|
||||
params: {
|
||||
parse: ({ id }) => ({ id: parseInt(id) }),
|
||||
stringify: ({ id }) => ({ id: id.toString() }),
|
||||
},
|
||||
loader: ({ context: { queryClient }, params: { id } }) => queryClient.ensureQueryData(profileQueryOptions(id)),
|
||||
beforeLoad: () => {
|
||||
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
|
||||
},
|
||||
component: ProfilePage,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Snackbar } from '@mui/material';
|
||||
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { t } from 'i18next';
|
||||
import Api from '../../api/Api';
|
||||
import Profile from '../../components/Profile/Profile';
|
||||
import { ROUTES } from '../../types/Routes';
|
||||
|
||||
const profileQueryOptions = queryOptions({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => Api.user(),
|
||||
});
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar open={isFetching} message={t('Updating')} />
|
||||
<Profile user={profileQuery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
|
||||
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileQueryOptions),
|
||||
beforeLoad: () => {
|
||||
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
|
||||
},
|
||||
component: ProfilePage,
|
||||
});
|
||||
@@ -1,27 +1,14 @@
|
||||
import type {} from '@redux-devtools/extension';
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { User } from '../types/User';
|
||||
|
||||
interface GuestBookState {
|
||||
user: User | undefined;
|
||||
token: string | undefined;
|
||||
setUser: (user?: User, token?: string) => void;
|
||||
}
|
||||
interface GuestBookState {}
|
||||
|
||||
const useGuestBookStore = create<GuestBookState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: undefined,
|
||||
// FIXME: Currentlay auth token, switch this to be reauth token
|
||||
token: undefined,
|
||||
setUser: (user?: User, token?: string) => set(() => ({ user, token })),
|
||||
}),
|
||||
{
|
||||
name: 'guestbook-storage',
|
||||
}
|
||||
)
|
||||
persist(() => ({}), {
|
||||
name: 'guestbook-storage',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ROUTES {
|
||||
INDEX = '/',
|
||||
PROFILE = '/profile',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Timestamp } from '../types/Timestamp';
|
||||
|
||||
/**
|
||||
* Convert date to nicer format
|
||||
* @param date Timestamp in question
|
||||
* @returns Formatted string
|
||||
*/
|
||||
const convertDate = (date: Timestamp): string => {
|
||||
return new Date(date.date).toLocaleString(navigator.languages[0] ?? 'de-DE', {
|
||||
timeZone: date.timezone,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default convertDate;
|
||||
@@ -1,5 +1,10 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
export enum ERRORS {
|
||||
NOT_FOUND = 'NotFound',
|
||||
UNAUTHORIZED = 'Unauthorized',
|
||||
}
|
||||
|
||||
/**
|
||||
* Return translated error
|
||||
* @param error Error object
|
||||
@@ -18,9 +23,9 @@ const handleError = (
|
||||
|
||||
if (error.code) {
|
||||
switch (error.code) {
|
||||
case 'NotFound':
|
||||
case ERRORS.NOT_FOUND:
|
||||
return t(error.code, { context: `${error.entity}:${context}` });
|
||||
case 'Unauthorized':
|
||||
case ERRORS.UNAUTHORIZED:
|
||||
return t(error.code, { context });
|
||||
default:
|
||||
return t('Unknown', { context });
|
||||
|
||||
Reference in New Issue
Block a user