Initial Post list

This commit is contained in:
Kilian Hofmann 2024-07-25 20:55:35 +02:00
parent 9a2673aba2
commit bb1e0eebf5
16 changed files with 327 additions and 96 deletions

View File

@ -124,7 +124,7 @@ paths:
[ [
{ {
"id": 0, "id": 0,
"user": { "username": "string" }, "user": { "username": "string", "image": "string" },
"content": "string", "content": "string",
"postedAt": "postedAt":
{ {

File diff suppressed because one or more lines are too long

View File

@ -16,16 +16,18 @@ class Post implements JsonSerializable
private int $id; private int $id;
// User is set if the post was fetched by an authenticated user // User is set if the post was fetched by an authenticated user
private ?User $user; private ?User $user;
// Name is set if the post was fetched by a non authenticated user // Name and image are set if the post was fetched by a non authenticated user
private ?string $name; private ?string $name;
private ?string $image;
private string $content; private string $content;
private DateTime $postedAt; private DateTime $postedAt;
private function __construct(int $id, ?User $user, ?string $name, string $content, string $postedAt) private function __construct(int $id, ?User $user, ?string $name, ?string $image, string $content, string $postedAt)
{ {
$this->id = $id; $this->id = $id;
$this->user = $user; $this->user = $user;
$this->name = $name; $this->name = $name;
$this->image = $image;
$this->content = $content; $this->content = $content;
$this->postedAt = new DateTime($postedAt); $this->postedAt = new DateTime($postedAt);
} }
@ -53,11 +55,13 @@ class Post implements JsonSerializable
if (!$data) throw new Exception("NotFound"); if (!$data) throw new Exception("NotFound");
$user = User::getByID($data["benutzer_id"]); $user = User::getByID($data["benutzer_id"]);
return new Post($data["id"], $user, null, $data["beitrag"], $data["zeitstempel"]); return new Post($data["id"], $user, null, null, $data["beitrag"], $data["zeitstempel"]);
} }
public static function create(User $user, string $content): Post public static function create(User $user, string $content): Post
{ {
$content = substr(trim($content), 0, 250);
$db = Database::getInstance(); $db = Database::getInstance();
$stmt = $db->prepare( $stmt = $db->prepare(
@ -98,7 +102,7 @@ class Post implements JsonSerializable
$list = array_map( $list = array_map(
function ($item) use ($authed) { function ($item) use ($authed) {
$user = User::getByID($item["benutzer_id"]); $user = User::getByID($item["benutzer_id"]);
return new Post($item["id"], $authed ? $user : null, !$authed ? $user->getUsername() : null, $item["beitrag"], $item["zeitstempel"]); return new Post($item["id"], $authed ? $user : null, !$authed ? $user->getUsername() : null, !$authed ? $user->getImage() : null, $item["beitrag"], $item["zeitstempel"]);
}, },
$data $data
); );
@ -115,12 +119,14 @@ class Post implements JsonSerializable
$db = Database::getInstance(); $db = Database::getInstance();
if (!empty($content)) { if (!empty($content)) {
$content = substr(trim($content), 0, 250);
$stmt = $db->prepare("UPDATE egb_gaestebuch SET beitrag = :CON WHERE id = :ID"); $stmt = $db->prepare("UPDATE egb_gaestebuch SET beitrag = :CON WHERE id = :ID");
$stmt->bindValue(":CON", $content); $stmt->bindValue(":CON", nl2br(htmlspecialchars($content)));
$stmt->bindValue(":ID", $this->id); $stmt->bindValue(":ID", $this->id);
try { try {
if (!$stmt->execute()) throw ApiError::failedUpdate(["content"]); if (!$stmt->execute()) throw ApiError::failedUpdate(["content"]);
} catch (Exception $e) { } catch (Exception) {
throw ApiError::failedUpdate(["content"]); throw ApiError::failedUpdate(["content"]);
} }
} }
@ -169,6 +175,7 @@ class Post implements JsonSerializable
{ {
$user = $this->user ? $this->user : [ $user = $this->user ? $this->user : [
"username" => $this->name, "username" => $this->name,
"image" => $this->image,
]; ];
return [ return [

View File

@ -188,7 +188,7 @@ class User implements JsonSerializable
egb_benutzer(benutzer, passwort, email, confirmationcode) egb_benutzer(benutzer, passwort, email, confirmationcode)
VALUES(:USR, :PAS, :EMA, :COD)" VALUES(:USR, :PAS, :EMA, :COD)"
); );
$stmt->bindValue(":USR", $username); $stmt->bindValue(":USR", htmlspecialchars($username));
$stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT)); $stmt->bindValue(":PAS", password_hash($password, PASSWORD_DEFAULT));
$stmt->bindValue(":EMA", $email); $stmt->bindValue(":EMA", $email);
$stmt->bindValue(":COD", $guid); $stmt->bindValue(":COD", $guid);
@ -277,7 +277,7 @@ class User implements JsonSerializable
$failed = []; $failed = [];
if (!empty($username)) { if (!empty($username)) {
$stmt = $db->prepare("UPDATE egb_benutzer SET benutzer = :USR WHERE id = :ID"); $stmt = $db->prepare("UPDATE egb_benutzer SET benutzer = :USR WHERE id = :ID");
$stmt->bindValue(":USR", $username); $stmt->bindValue(":USR", htmlspecialchars($username));
$stmt->bindValue(":ID", $this->id); $stmt->bindValue(":ID", $this->id);
try { try {
if (!$stmt->execute()) array_push($failed, "username"); if (!$stmt->execute()) array_push($failed, "username");
@ -378,7 +378,7 @@ class User implements JsonSerializable
'id' => $this->id, 'id' => $this->id,
'username' => $this->username, 'username' => $this->username,
'status' => $this->status, 'status' => $this->status,
'email' => $this->email, 'email' => htmlspecialchars($this->email),
'image' => $this->image, 'image' => $this->image,
'isAdmin' => $this->isAdmin, 'isAdmin' => $this->isAdmin,
'memberSince' => $this->memberSince, 'memberSince' => $this->memberSince,

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@fontsource/roboto": "^5.0.13",
"@mui/icons-material": "^5.16.4", "@mui/icons-material": "^5.16.4",
"@mui/material": "^5.16.4", "@mui/material": "^5.16.4",
"@tanstack/react-form": "^0.26.4", "@tanstack/react-form": "^0.26.4",

View File

@ -14,6 +14,9 @@ importers:
'@emotion/styled': '@emotion/styled':
specifier: ^11.13.0 specifier: ^11.13.0
version: 11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1) version: 11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
'@fontsource/roboto':
specifier: ^5.0.13
version: 5.0.13
'@mui/icons-material': '@mui/icons-material':
specifier: ^5.16.4 specifier: ^5.16.4
version: 5.16.4(@mui/material@5.16.4(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.3)(react@18.3.1) version: 5.16.4(@mui/material@5.16.4(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.3)(react@18.3.1)
@ -441,6 +444,9 @@ packages:
resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@fontsource/roboto@5.0.13':
resolution: {integrity: sha512-j61DHjsdUCKMXSdNLTOxcG701FWnF0jcqNNQi2iPCDxU8seN/sMxeh62dC++UiagCWq9ghTypX+Pcy7kX+QOeQ==}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -2234,6 +2240,8 @@ snapshots:
'@eslint/js@8.57.0': {} '@eslint/js@8.57.0': {}
'@fontsource/roboto@5.0.13': {}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3

View File

@ -1,3 +1,4 @@
import { PostListAuth, PostListNonAuth } from '../types/Post';
import { User } from '../types/User'; import { User } from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/'; const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@ -5,28 +6,41 @@ const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
let instance: ApiImpl; let instance: ApiImpl;
class ApiImpl { class ApiImpl {
private token: string = ''; //FIXME: PRIVATE when reauth token exists
public token?: string;
constructor() { constructor() {
if (instance) { if (instance) {
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 //eslint-disable-next-line @typescript-eslint/no-this-alias
instance = this; instance = this;
} }
public logIn = async (email: string, password: string): Promise<User> => { public hasAuth = () => this.token !== undefined;
//FIXME: Currently returns Auth token, switch to reauth token
public logIn = async (email: string, password: string): Promise<[User, string]> => {
const { user, token } = await (await this.post('login', { email, password })).json(); const { user, token } = await (await this.post('login', { email, password })).json();
this.token = token; this.token = token;
return user; return [user, token];
}; };
public logOut = async (): Promise<boolean> => { public logOut = async (): Promise<boolean> => {
this.token = undefined;
return await (await this.postAuth('logout')).json(); return await (await this.postAuth('logout')).json();
}; };
public posts = async (page?: number): Promise<PostListNonAuth | PostListAuth> => {
const url = `posts?p=${page ?? 0}&l=9`;
if (this.token) return await (await this.getAuth(url)).json();
return await (await this.get(url)).json();
};
private post = async ( private post = async (
endpoint: string, endpoint: string,
body: Record<string, unknown> | undefined = undefined, body: Record<string, unknown> | undefined = undefined,
@ -50,12 +64,32 @@ class ApiImpl {
const response = await fetch(`${BASE}${endpoint}`, { const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors', mode: 'cors',
method: 'post', method: 'post',
headers: { token: this.token, ...headers }, headers: { token: this.token ?? '', ...headers },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (response.ok) return response; if (response.ok) return response;
throw await response.json(); throw await response.json();
}; };
private get = async (endpoint: string, headers: HeadersInit | undefined = undefined) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'get',
headers,
});
if (response.ok) return response;
throw await response.json();
};
private getAuth = async (endpoint: string, headers: HeadersInit | undefined = undefined) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'get',
headers: { token: this.token ?? '', ...headers },
});
if (response.ok) return response;
throw await response.json();
};
} }
const Api = new ApiImpl(); const Api = new ApiImpl();

View File

@ -1,5 +1,6 @@
import { Box, Button, TextField, Typography } from '@mui/material'; import { Box, Button, TextField, Typography } from '@mui/material';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api'; import Api from '../../../api/Api';
@ -12,6 +13,7 @@ const Login: FC = () => {
const setUser = useGuestBookStore((state) => state.setUser); const setUser = useGuestBookStore((state) => state.setUser);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -20,8 +22,10 @@ const Login: FC = () => {
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
try { try {
setUser(await Api.logIn(value.email, value.password)); const [user, token] = await Api.logIn(value.email, value.password);
// eslint-disable-next-line @typescript-eslint/no-explicit-any setUser(user, token);
router.invalidate();
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
setError(error); setError(error);
} }
@ -42,7 +46,7 @@ const Login: FC = () => {
}} }}
noValidate noValidate
> >
<Box sx={{ display: 'grid', gap: 1, padding: 1, minWidth: '100px' }}> <Box sx={{ display: 'grid', gap: 2, padding: 1 }}>
<form.Field <form.Field
name="email" name="email"
validators={{ validators={{
@ -67,6 +71,9 @@ const Login: FC = () => {
required required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0} error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''} helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
type="email"
autoComplete="username"
inputMode="email"
/> />
</> </>
); );
@ -96,6 +103,8 @@ const Login: FC = () => {
required required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0} error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''} helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
type="password"
autoComplete="password"
/> />
</> </>
); );
@ -111,7 +120,7 @@ const Login: FC = () => {
</> </>
)} )}
/> />
{error && <Typography color="error.main">{t(...handleError(error, 'login'))}</Typography>} {error && <Typography color="error.main">{handleError(error, 'login', t)}</Typography>}
</Box> </Box>
</form> </form>
); );

View File

@ -1,5 +1,17 @@
import { AccountCircle } from '@mui/icons-material'; import { AccountCircle } from '@mui/icons-material';
import { AppBar, Avatar, IconButton, Menu, MenuItem, Toolbar, Typography, useScrollTrigger } from '@mui/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 { cloneElement, FC, ReactElement, useState } from 'react'; import { cloneElement, FC, ReactElement, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Api from '../../api/Api'; import Api from '../../api/Api';
@ -24,6 +36,8 @@ const Header: FC = () => {
const setUser = useGuestBookStore((state) => state.setUser); const setUser = useGuestBookStore((state) => state.setUser);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const isLoading = useRouterState({ select: (s) => s.status === 'pending' });
const handleMenu = (event: React.MouseEvent<HTMLElement>) => { const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@ -35,56 +49,68 @@ const Header: FC = () => {
return ( return (
<ElevationScroll> <ElevationScroll>
<AppBar> <>
<Toolbar> <AppBar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> <Toolbar>
{t('GuestBook')} <Box sx={{ flexGrow: 1, alignItems: 'center', display: 'flex', gap: 1 }}>
</Typography> <MUILink component={Link} to="/" color="#FFF" variant="h6" underline="none">
{user ? ( {t('GuestBook')}
<IconButton onClick={handleMenu} sx={{ p: 0 }}> </MUILink>
<Avatar alt={user.username} src={`storage/${user.image}`} /> {isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
</IconButton> </Box>
) : (
<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 ? ( {user ? (
[ <IconButton onClick={handleMenu} sx={{ p: 0 }}>
<MenuItem key="profile" onClick={handleClose}> <Avatar alt={user.username} src={`storage/${user.image}`} />
{t('Profile')} </IconButton>
</MenuItem>,
<MenuItem
key="logout"
onClick={() => {
Api.logOut();
setUser(undefined);
}}
>
{t('Log out')}
</MenuItem>,
]
) : ( ) : (
<Login /> <IconButton size="large" onClick={handleMenu} color="inherit">
<AccountCircle />
</IconButton>
)} )}
</Menu> <Menu
</Toolbar> id="menu-appbar"
</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>
</Toolbar>
</AppBar>
<Toolbar />
</>
</ElevationScroll> </ElevationScroll>
); );
}; };

View File

@ -0,0 +1,38 @@
import { Avatar, Card, CardContent, CardHeader, Link as MUILink, Typography } from '@mui/material';
import { Link } from '@tanstack/react-router';
import { FC } from 'react';
import { PostAuth, PostNonAuth } from '../../types/Post';
interface Props {
post: PostNonAuth | PostAuth;
}
const Post: FC<Props> = ({ post }) => {
return (
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardHeader
avatar={
<MUILink component={Link} to="/" color="#FFF" variant="h6" underline="none">
<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',
})}
/>
<CardContent>
<Typography>{post.content}</Typography>
</CardContent>
</Card>
);
};
export default Post;

View File

@ -10,6 +10,12 @@ import { routeTree } from './routeTree.gen';
// Import i18n // Import i18n
import './i18n'; import './i18n';
// Font
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
// Query Client // Query Client
const queryClient = new QueryClient(); const queryClient = new QueryClient();

View File

@ -2,15 +2,23 @@ import { Toolbar } from '@mui/material';
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { createRootRouteWithContext, 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 Api from '../api/Api';
import Header from '../components/Header/Header'; import Header from '../components/Header/Header';
import useGuestBookStore from '../store/store';
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: () => ( component: () => {
<> //FIXME: REAUTH HERE
<Header /> const token = useGuestBookStore((state) => state.token);
<Toolbar /> Api.token = token;
<Outlet />
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />} return (
</> <>
), <Header />
<Toolbar />
<Outlet />
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</>
);
},
}); });

View File

@ -1,9 +1,57 @@
import { createFileRoute } from '@tanstack/react-router'; import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import Api from '../api/Api';
import Post from '../components/Post/Post';
const postsQueryOptions = (page?: number) =>
queryOptions({
queryKey: ['posts', { page: page ?? 0, hasAuth: Api.hasAuth() }],
queryFn: () => Api.posts(page),
});
export const Route = createFileRoute('/')({ 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, component: Home,
}); });
function Home() { function Home() {
return <></>; const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
return (
<>
<Snackbar open={isFetching} message="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>
))}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
page={(page ?? 0) + 1}
count={postsQuery.pages}
color="primary"
renderItem={(item) => (
<PaginationItem
{...item}
component={Link}
to="/"
search={{ page: item.page ? item.page - 1 : undefined }}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={(e) => item.onClick(e as any)}
/>
)}
/>
</Grid>
</Grid>
</>
);
} }

View File

@ -1,18 +1,28 @@
import type {} from '@redux-devtools/extension'; // required for devtools typing import type {} from '@redux-devtools/extension';
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { User } from '../types/User'; import { User } from '../types/User';
interface GuestBookState { interface GuestBookState {
user: User | undefined; user: User | undefined;
setUser: (user: User | undefined) => void; token: string | undefined;
setUser: (user?: User, token?: string) => void;
} }
const useGuestBookStore = create<GuestBookState>()( const useGuestBookStore = create<GuestBookState>()(
devtools((set) => ({ devtools(
user: undefined, persist(
setUser: (user: User | undefined) => set(() => ({ user })), (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',
}
)
)
); );
export default useGuestBookStore; export default useGuestBookStore;

View File

@ -0,0 +1,29 @@
import { Timestamp } from './Timestamp';
import { User } from './User';
export interface PostNonAuth {
id: number;
user: {
username: string;
image: string;
};
content: string;
postedAt: Timestamp;
}
export interface PostListNonAuth {
pages: number;
data: PostNonAuth[];
}
export interface PostAuth {
id: number;
user: User;
content: string;
postedAt: Timestamp;
}
export interface PostListAuth {
pages: number;
data: PostNonAuth[];
}

View File

@ -1,25 +1,32 @@
import { TFunction } from 'i18next';
/** /**
* Transform error into i18next spreadable input * Return translated error
* @param error Error object * @param error Error object
* @param context Optional context for translation * @param context Optional context
* @returns Array to be spread into i18next `t` function * @param t Optional translation function, defautls to pass through
* @returns Translated error or inputs if t as unspecified
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleError = (error: any, context?: string): [string, { context?: string }] => { const handleError = (
if (!error) return ['', {}]; error: any,
context?: string,
t: TFunction<'translation', undefined> | ((..._in: any) => any) = (..._in: any) => _in
): string => {
if (!error) return t('', {});
if (error.code) { if (error.code) {
switch (error.code) { switch (error.code) {
case 'NotFound': case 'NotFound':
return [error.code, { context: `${error.entity}:${context}` }]; return t(error.code, { context: `${error.entity}:${context}` });
case 'Unauthorized': case 'Unauthorized':
return [error.code, { context }]; return t(error.code, { context });
default: default:
return ['Unknown', { context }]; return t('Unknown', { context });
} }
} }
return [error?.message ?? 'Unknown', { context }]; return t(error?.message ?? 'Unknown', { context });
}; };
export default handleError; export default handleError;