Delete Post

This commit is contained in:
Kilian Hofmann 2024-07-26 20:56:32 +02:00
parent 0929371c10
commit a515c447e0
14 changed files with 180 additions and 56 deletions

View File

@ -139,6 +139,7 @@ class Post implements JsonSerializable
$db = Database::getInstance(); $db = Database::getInstance();
$stmt = $db->prepare("DELETE FROM egb_gaestebuch WHERE id = :ID"); $stmt = $db->prepare("DELETE FROM egb_gaestebuch WHERE id = :ID");
$stmt->bindValue(":ID", $this->id); $stmt->bindValue(":ID", $this->id);
$stmt->execute();
return $this; return $this;
} }

View File

@ -1,6 +1,8 @@
{ {
"NotFound_user:login": "Benutzer existiert nicht", "NotFound_user:login": "Benutzer existiert nicht",
"Unauthorized_login": "Ungültige E-Mail oder Passwort", "Unauthorized_login": "Ungültige E-Mail oder Passwort",
"Unauthorized_delete": "Keine Berechtigung",
"NotFound_post:delete": "Post nicht gefunden",
"GuestBook": "Gästebuch", "GuestBook": "Gästebuch",
@ -18,5 +20,13 @@
"Username": "Benutzername", "Username": "Benutzername",
"Member since": "Mitglied seit", "Member since": "Mitglied seit",
"Post count": "Anzahl Posts" "Post count": "Anzahl Posts",
"Edit": "Bearbeiten",
"Delete": "Löschen",
"Yes": "Ja",
"No": "Nein",
"Confirm post delete title": "Diesen Post löschen?",
"Confirm post delete body": "Möchtest du diesen Post von {{name}} wirklich Löschen?"
} }

View File

@ -2,6 +2,9 @@
"NotFound_user:login": "User does not exist", "NotFound_user:login": "User does not exist",
"Unauthorized_login": "Invalid email or password", "Unauthorized_login": "Invalid email or password",
"Unauthorized_delete": "Unauthorized",
"NotFound_post:delete": "Post not found",
"GuestBook": "GuestBook", "GuestBook": "GuestBook",
"Email": "Email", "Email": "Email",
@ -18,5 +21,13 @@
"Username": "Username", "Username": "Username",
"Member since": "Member since", "Member since": "Member since",
"Post count": "Post count" "Post count": "Post count",
"Edit": "Edit",
"Delete": "Delete",
"Yes": "Yes",
"No": "No",
"Confirm post delete title": "Delete this post?",
"Confirm post delete body": "Do you really want to delete this post by {{name}}?"
} }

View File

@ -1,4 +1,4 @@
import { PostListAuth, PostListNonAuth } from '../types/Post'; import { PostAuth, 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/';
@ -27,7 +27,6 @@ class ApiImpl {
public logIn = async (email: string, password: string): Promise<void> => { public logIn = async (email: string, password: string): Promise<void> => {
const { user, token } = await (await this.post('login', { email, password })).json(); const { user, token } = await (await this.post('login', { email, password })).json();
this.self = user; this.self = user;
this.isAdmin = user.isAdmin;
this.token = token; this.token = token;
}; };
@ -50,15 +49,17 @@ class ApiImpl {
return await (await this.get(url)).json(); return await (await this.get(url)).json();
}; };
public deletePost = async (id: number): Promise<PostAuth> => {
return await (await this.delete(`posts/${id}`)).json();
};
public user = async (id?: number): Promise<User> => { public user = async (id?: number): Promise<User> => {
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json(); return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
}; };
private post = async ( /* Internal */
endpoint: string,
body: Record<string, unknown> | undefined = undefined, private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
headers: HeadersInit | undefined = undefined
) => {
const response = await fetch(`${BASE}${endpoint}`, { const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors', mode: 'cors',
method: 'post', method: 'post',
@ -69,11 +70,7 @@ class ApiImpl {
throw await response.json(); throw await response.json();
}; };
private postAuth = async ( private postAuth = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
endpoint: string,
body: Record<string, unknown> | undefined = undefined,
headers: HeadersInit | undefined = undefined
) => {
const response = await fetch(`${BASE}${endpoint}`, { const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors', mode: 'cors',
method: 'post', method: 'post',
@ -84,7 +81,7 @@ class ApiImpl {
throw await response.json(); throw await response.json();
}; };
private get = async (endpoint: string, headers: HeadersInit | undefined = undefined) => { private get = async (endpoint: string, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, { const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors', mode: 'cors',
method: 'get', method: 'get',
@ -94,7 +91,7 @@ class ApiImpl {
throw await response.json(); throw await response.json();
}; };
private getAuth = async (endpoint: string, headers: HeadersInit | undefined = undefined) => { private getAuth = async (endpoint: string, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, { const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors', mode: 'cors',
method: 'get', method: 'get',
@ -103,6 +100,16 @@ class ApiImpl {
if (response.ok) return response; if (response.ok) return response;
throw await response.json(); throw await response.json();
}; };
private delete = async (endpoint: string, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'delete',
headers: { token: this.token ?? '', ...headers },
});
if (response.ok) return response;
throw await response.json();
};
} }
const Api = new ApiImpl(); const Api = new ApiImpl();

View File

@ -52,10 +52,9 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
name="email" name="email"
validators={{ validators={{
onChange: ({ value }) => (!value ? t('Email required') : undefined), onChange: ({ value }) => (!value ? t('Email required') : undefined),
onChangeAsyncDebounceMs: 500, onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => { onChangeAsync: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000)); return !value && t('Email required');
return value.includes('error') && 'No "error" allowed in email';
}, },
}} }}
children={(field) => { children={(field) => {
@ -84,10 +83,9 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
name="password" name="password"
validators={{ validators={{
onChange: ({ value }) => (!value ? t('Password required') : undefined), onChange: ({ value }) => (!value ? t('Password required') : undefined),
onChangeAsyncDebounceMs: 500, onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => { onChangeAsync: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000)); return !value && t('Password required');
return value.includes('error') && 'No "error" allowed in password';
}, },
}} }}
children={(field) => { children={(field) => {
@ -121,7 +119,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
</> </>
)} )}
/> />
{error && <Typography color="error.main">{handleError(error, 'login', t)}</Typography>} {error && <Typography color="error.main">{handleError(error, t, 'login')}</Typography>}
</Box> </Box>
</form> </form>
); );

View File

@ -1,4 +1,4 @@
import { AccountCircle, Translate } from '@mui/icons-material'; import { AccountCircle, Person, Translate } from '@mui/icons-material';
import { import {
AppBar, AppBar,
Avatar, Avatar,
@ -57,7 +57,9 @@ const Header: FC = () => {
</IconButton> </IconButton>
{user ? ( {user ? (
<IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}> <IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}>
<Avatar alt={user.username} src={`storage/${user.image}`} /> <Avatar alt={user.username} src={`storage/${user.image}`}>
<Person />
</Avatar>
</IconButton> </IconButton>
) : ( ) : (
<IconButton size="large" onClick={(event) => setAnchorUserMenu(event.currentTarget)} color="inherit"> <IconButton size="large" onClick={(event) => setAnchorUserMenu(event.currentTarget)} color="inherit">

View File

@ -1,25 +1,67 @@
import { Avatar, Card, CardContent, CardHeader, Link as MUILink, Typography } from '@mui/material'; import { Person } from '@mui/icons-material';
import {
Alert,
Avatar,
Button,
Card,
CardActions,
CardContent,
CardHeader,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Link as MUILink,
Snackbar,
Typography,
} from '@mui/material';
import { useMutation } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { FC } from 'react'; import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api'; import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post'; import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date'; import convertDate from '../../utils/date';
import handleError from '../../utils/errors';
interface Props { interface Props {
post: PostNonAuth | PostAuth; post: PostNonAuth | PostAuth;
} }
const Post: FC<Props> = ({ post }) => { const Post: FC<Props> = ({ post }) => {
const deleteMutation = useMutation({
mutationFn: (id: number) => {
return Api.deletePost(id);
},
});
const [open, setOpen] = useState(false);
const { t } = useTranslation();
return ( return (
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}> <Card>
<CardHeader <CardHeader
avatar={ avatar={
'id' in post.user ? ( 'id' in post.user ? (
<MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }} underline="none"> post.user.id !== Api.getAuthenticatedUser()?.id ? (
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} /> <MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }}>
<Avatar alt={post.user.username} src={`storage/${post.user.image}`}>
<Person />
</Avatar>
</MUILink> </MUILink>
) : ( ) : (
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} /> <MUILink component={Link} to="/profile">
<Avatar alt={post.user.username} src={`storage/${post.user.image}`}>
<Person />
</Avatar>
</MUILink>
)
) : (
<Avatar alt={post.user.username} src={`storage/${post.user.image}`}>
<Person />
</Avatar>
) )
} }
title={ title={
@ -42,6 +84,42 @@ const Post: FC<Props> = ({ post }) => {
<CardContent> <CardContent>
<Typography>{post.content}</Typography> <Typography>{post.content}</Typography>
</CardContent> </CardContent>
<CardActions>
{Api.isAdmin() && (
<>
<Button size="small" color="error" onClick={() => setOpen(true)}>
{t('Delete')}
</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>{t('Confirm post delete title')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('Confirm post delete body', { name: post.user.username })}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} autoFocus variant="contained">
{t('No')}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => {
deleteMutation.mutate(post.id);
setOpen(false);
}}
>
{t('Yes')}
</Button>
</DialogActions>
</Dialog>
</>
)}
</CardActions>
<Snackbar open={deleteMutation.isError} autoHideDuration={2000} onClose={() => deleteMutation.reset()}>
<Alert severity="error" variant="filled" sx={{ width: '100%' }}>
{deleteMutation.isError && handleError(deleteMutation.error, t, 'delete')}
</Alert>
</Snackbar>
</Card> </Card>
); );
}; };

View File

@ -1,4 +1,5 @@
import { Avatar, Box, Grid, Typography } from '@mui/material'; import { Person } from '@mui/icons-material';
import { Avatar, Box, Button, Card, CardActions, CardContent, Grid, Typography } from '@mui/material';
import { FC } from 'react'; import { FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { User } from '../../types/User'; import { User } from '../../types/User';
@ -6,28 +7,37 @@ import convertDate from '../../utils/date';
interface Props { interface Props {
user: User; user: User;
canEdit?: boolean;
} }
const Profile: FC<Props> = ({ user }) => { const Profile: FC<Props> = ({ user, canEdit }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box> <Card>
<CardContent>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item sx={{ display: 'grid', gridTemplateColumns: 'fit-content(100%) 1fr', columnGap: 2, rowGap: 1 }}> <Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}>
<Box sx={{ gridColumn: '1/3', display: 'flex', justifyContent: 'center' }}> <Avatar alt={user.username} src={`storage/${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Avatar alt={user.username} src={`storage/${user.image}`} sx={{ width: 100, height: 100 }} /> <Person sx={{ width: '60px', height: '60px' }} />
</Box> </Avatar>
</Grid>
<Grid item sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', columnGap: 1 }}>
<Typography fontWeight="bold">{t('Username')}:</Typography> <Typography fontWeight="bold">{t('Username')}:</Typography>
<Typography>{user.username}</Typography> <Typography>{user.username}</Typography>
<Typography fontWeight="bold">{t('Email')}:</Typography>
<Typography>{user.email}</Typography>
<Typography fontWeight="bold">{t('Member since')}:</Typography> <Typography fontWeight="bold">{t('Member since')}:</Typography>
<Typography>{convertDate(user.memberSince)}</Typography> <Typography>{convertDate(user.memberSince)}</Typography>
<Typography fontWeight="bold">{t('Post count')}:</Typography> <Typography fontWeight="bold">{t('Post count')}:</Typography>
<Typography>{user.postCount}</Typography> <Typography>{user.postCount}</Typography>
</Grid>
<Grid item></Grid>
</Grid>
</Box> </Box>
</Grid>
</Grid>
</CardContent>
<CardActions>{canEdit && <Button size="small">{t('Edit')}</Button>}</CardActions>
</Card>
); );
}; };

View File

@ -1,3 +1,4 @@
import { Box } from '@mui/material';
import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query'; import { QueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router'; import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools'; import { TanStackRouterDevtools } from '@tanstack/router-devtools';
@ -13,7 +14,11 @@ const Root = () => {
return ( return (
<> <>
<Header /> <Header />
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ maxWidth: '800px' }}>
<Outlet /> <Outlet />
</Box>
</Box>
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />} {process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</> </>
); );

View File

@ -22,7 +22,7 @@ const Home = () => {
<Snackbar open={isFetching} message={t('Updating')} /> <Snackbar open={isFetching} message={t('Updating')} />
<Grid container spacing={2}> <Grid container spacing={2}>
{postsQuery.data.map((post) => ( {postsQuery.data.map((post) => (
<Grid key={post.id} item xs={12} md={6} lg={4} sx={{ display: 'flex' }}> <Grid item xs={12} key={post.id}>
<Post post={post} /> <Post post={post} />
</Grid> </Grid>
))} ))}

View File

@ -19,7 +19,7 @@ const ProfilePage = () => {
return ( return (
<> <>
<Snackbar open={isFetching} message={t('Updating')} /> <Snackbar open={isFetching} message={t('Updating')} />
<Profile user={profileQuery} /> <Profile user={profileQuery} canEdit={Api.isAdmin()} />
</> </>
); );
}; };
@ -30,8 +30,9 @@ export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
stringify: ({ id }) => ({ id: id.toString() }), stringify: ({ id }) => ({ id: id.toString() }),
}, },
loader: ({ context: { queryClient }, params: { id } }) => queryClient.ensureQueryData(profileQueryOptions(id)), loader: ({ context: { queryClient }, params: { id } }) => queryClient.ensureQueryData(profileQueryOptions(id)),
beforeLoad: () => { beforeLoad: ({ params: { id } }) => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX }); if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
if (id === Api.getAuthenticatedUser()?.id) throw redirect({ to: ROUTES.PROFILE });
}, },
component: ProfilePage, component: ProfilePage,
}); });

View File

@ -17,7 +17,7 @@ const ProfilePage = () => {
return ( return (
<> <>
<Snackbar open={isFetching} message={t('Updating')} /> <Snackbar open={isFetching} message={t('Updating')} />
<Profile user={profileQuery} /> <Profile user={profileQuery} canEdit={true} />
</> </>
); );
}; };

View File

@ -15,10 +15,11 @@ export enum ERRORS {
const handleError = ( const handleError = (
//eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any, error: any,
context?: string,
//eslint-disable-next-line @typescript-eslint/no-explicit-any //eslint-disable-next-line @typescript-eslint/no-explicit-any
t: TFunction<'translation', undefined> | ((..._in: any) => any) = (..._in: any) => _in t: TFunction<'translation', undefined> | ((..._in: any) => any) = (..._in: any) => _in,
context?: string
): string => { ): string => {
console.log(context);
if (!error) return t('', {}); if (!error) return t('', {});
if (error.code) { if (error.code) {