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();
$stmt = $db->prepare("DELETE FROM egb_gaestebuch WHERE id = :ID");
$stmt->bindValue(":ID", $this->id);
$stmt->execute();
return $this;
}

View File

@ -1,6 +1,8 @@
{
"NotFound_user:login": "Benutzer existiert nicht",
"Unauthorized_login": "Ungültige E-Mail oder Passwort",
"Unauthorized_delete": "Keine Berechtigung",
"NotFound_post:delete": "Post nicht gefunden",
"GuestBook": "Gästebuch",
@ -18,5 +20,13 @@
"Username": "Benutzername",
"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",
"Unauthorized_login": "Invalid email or password",
"Unauthorized_delete": "Unauthorized",
"NotFound_post:delete": "Post not found",
"GuestBook": "GuestBook",
"Email": "Email",
@ -18,5 +21,13 @@
"Username": "Username",
"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';
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> => {
const { user, token } = await (await this.post('login', { email, password })).json();
this.self = user;
this.isAdmin = user.isAdmin;
this.token = token;
};
@ -50,15 +49,17 @@ class ApiImpl {
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> => {
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
};
private post = async (
endpoint: string,
body: Record<string, unknown> | undefined = undefined,
headers: HeadersInit | undefined = undefined
) => {
/* Internal */
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'post',
@ -69,11 +70,7 @@ class ApiImpl {
throw await response.json();
};
private postAuth = async (
endpoint: string,
body: Record<string, unknown> | undefined = undefined,
headers: HeadersInit | undefined = undefined
) => {
private postAuth = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'post',
@ -84,7 +81,7 @@ class ApiImpl {
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}`, {
mode: 'cors',
method: 'get',
@ -94,7 +91,7 @@ class ApiImpl {
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}`, {
mode: 'cors',
method: 'get',
@ -103,6 +100,16 @@ class ApiImpl {
if (response.ok) return response;
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();

View File

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

View File

@ -1,4 +1,4 @@
import { AccountCircle, Translate } from '@mui/icons-material';
import { AccountCircle, Person, Translate } from '@mui/icons-material';
import {
AppBar,
Avatar,
@ -57,7 +57,9 @@ const Header: FC = () => {
</IconButton>
{user ? (
<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 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 { FC } from 'react';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
import handleError from '../../utils/errors';
interface Props {
post: PostNonAuth | PostAuth;
}
const Post: FC<Props> = ({ post }) => {
const deleteMutation = useMutation({
mutationFn: (id: number) => {
return Api.deletePost(id);
},
});
const [open, setOpen] = useState(false);
const { t } = useTranslation();
return (
<Card sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<Card>
<CardHeader
avatar={
'id' in post.user ? (
<MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }} underline="none">
<Avatar alt={post.user.username} src={`storage/${post.user.image}`} />
</MUILink>
post.user.id !== Api.getAuthenticatedUser()?.id ? (
<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 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}`} />
<Avatar alt={post.user.username} src={`storage/${post.user.image}`}>
<Person />
</Avatar>
)
}
title={
@ -42,6 +84,42 @@ const Post: FC<Props> = ({ post }) => {
<CardContent>
<Typography>{post.content}</Typography>
</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>
);
};

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 { useTranslation } from 'react-i18next';
import { User } from '../../types/User';
@ -6,28 +7,37 @@ import convertDate from '../../utils/date';
interface Props {
user: User;
canEdit?: boolean;
}
const Profile: FC<Props> = ({ user }) => {
const Profile: FC<Props> = ({ user, canEdit }) => {
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>
<Card>
<CardContent>
<Grid container spacing={2}>
<Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}>
<Avatar alt={user.username} src={`storage/${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Person sx={{ width: '60px', height: '60px' }} />
</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>{user.username}</Typography>
<Typography fontWeight="bold">{t('Email')}:</Typography>
<Typography>{user.email}</Typography>
<Typography fontWeight="bold">{t('Member since')}:</Typography>
<Typography>{convertDate(user.memberSince)}</Typography>
<Typography fontWeight="bold">{t('Post count')}:</Typography>
<Typography>{user.postCount}</Typography>
</Box>
</Grid>
</Grid>
<Grid item></Grid>
</Grid>
</Box>
</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 { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useRouter } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
@ -13,7 +14,11 @@ const Root = () => {
return (
<>
<Header />
<Outlet />
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Box sx={{ maxWidth: '800px' }}>
<Outlet />
</Box>
</Box>
{process.env.NODE_ENV === 'development' && <TanStackRouterDevtools />}
</>
);

View File

@ -22,7 +22,7 @@ const Home = () => {
<Snackbar open={isFetching} message={t('Updating')} />
<Grid container spacing={2}>
{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} />
</Grid>
))}

View File

@ -19,7 +19,7 @@ const ProfilePage = () => {
return (
<>
<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() }),
},
loader: ({ context: { queryClient }, params: { id } }) => queryClient.ensureQueryData(profileQueryOptions(id)),
beforeLoad: () => {
beforeLoad: ({ params: { id } }) => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
if (id === Api.getAuthenticatedUser()?.id) throw redirect({ to: ROUTES.PROFILE });
},
component: ProfilePage,
});

View File

@ -17,7 +17,7 @@ const ProfilePage = () => {
return (
<>
<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 = (
//eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
context?: string,
//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 => {
console.log(context);
if (!error) return t('', {});
if (error.code) {