Profile Image edit

This commit is contained in:
2024-07-27 21:00:31 +02:00
parent 15ca7e8879
commit 627654e0a7
18 changed files with 368 additions and 38 deletions
+24 -6
View File
@@ -1,6 +1,6 @@
import { POST_LIMIT } from '../constanst';
import { PostAuth, PostListAuth, PostListNonAuth, PostUpdate } from '../types/Post';
import { User, UserUpdate } from '../types/User';
import { PostAuth, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post';
import { User, UserImageUpdate, UserUpdate } from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@@ -51,8 +51,8 @@ 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 deletePost = async (id: number): Promise<PostDelete> => {
return await (await this.delete(`posts/${id}?l=${POST_LIMIT}`)).json();
};
public user = async (id?: number): Promise<User> => {
@@ -63,8 +63,15 @@ class ApiImpl {
return await (await this.patch(`users/${id ?? 'self'}`, data as Record<string, unknown>)).json();
};
public newPost = async (data: PostUpdate): Promise<PostAuth> => {
return await (await this.postAuth(`posts`, data as Record<string, unknown>)).json();
public updateUserImage = async (data: UserImageUpdate, id?: number): Promise<User> => {
const formData = new FormData();
if (data.image) formData.append('image', data.image);
if (!data.image && data.predefined) formData.append('predefined', data.predefined);
return await (await this.postAuthRaw(`users/${id ?? 'self'}/image`, formData)).json();
};
public newPost = async (data: PostUpdate): Promise<PostNew> => {
return await (await this.postAuth(`posts?l=${POST_LIMIT}`, { ...data } as Record<string, unknown>)).json();
};
public updatePost = async (data: PostUpdate, id: number): Promise<PostAuth> => {
@@ -95,6 +102,17 @@ class ApiImpl {
throw await response.json();
};
private postAuthRaw = async (endpoint: string, body?: FormData, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'post',
headers: { token: this.token ?? '', ...headers },
body,
});
if (response.ok) return response;
throw await response.json();
};
private get = async (endpoint: string, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
@@ -0,0 +1,226 @@
import { CloudUpload, Delete, Person } from '@mui/icons-material';
import {
Avatar,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
Grid,
IconButton,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { FC, FormEvent, useState } from 'react';
import Api from '../../../api/Api';
import { User, UserImageUpdate } from '../../../types/User';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
user: User;
open: boolean;
onClose: () => void;
}
const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any>();
const updateMutation = useMutation({
mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => {
return Api.updateUserImage(data, id);
},
});
const form = useForm<UserImageUpdate>({
onSubmit: async ({ value }) => {
try {
updateMutation.mutate(
{ data: value, id: Api.getAuthenticatedUser()?.id === user.id ? undefined : user.id },
{
onSuccess: () => {
handleClose();
const queryKey = Api.getAuthenticatedUser()?.id === user.id ? ['profile'] : ['profile', { id: user.id }];
queryClient.invalidateQueries({ queryKey });
},
onError: setError,
}
);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setError(error);
}
},
});
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.only('xs'), { noSsr: true });
const queryClient = useQueryClient();
const formState = form.useStore((state) => ({ image: state.values.image, predefined: state.values.predefined }));
const handleClose = () => {
form.reset();
setError(undefined);
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
fullScreen={fullScreen}
PaperProps={{
component: 'form',
encType: 'multipart/form-data',
onSubmit: (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
},
onKeyDown: (event: React.KeyboardEvent<HTMLFormElement>) => {
if (event.key === 'Tab') {
event.stopPropagation();
}
},
noValidate: true,
}}
>
<DialogTitle>{t('Edit image')}</DialogTitle>
<DialogContent sx={{ gap: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Avatar
alt={user.username}
src={
formState.image
? URL.createObjectURL(formState.image)
: formState.predefined
? `storage/profilbilder/default/${formState.predefined}.svg`
: `storage/${user.image}`
}
sx={{ width: '100px', height: '100px' }}
>
<Person sx={{ width: '60px', height: '60px' }} />
</Avatar>
</Grid>
<Grid item xs={12}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
component="label"
role={undefined}
variant="contained"
tabIndex={-1}
startIcon={<CloudUpload />}
fullWidth
>
<form.Field
name="image"
children={(field) => (
<>
<Box sx={{ textOverflow: 'ellipsis', textWrap: 'nowrap', overflow: 'hidden' }}>
{!field.state.value ? t('Upload image') : t('Upload named', { name: field.state.value?.name })}
</Box>
<TextField
name={field.name}
onBlur={field.handleBlur}
value={!field.state.value ? '' : undefined}
//eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(e) => field.handleChange((e.target as any).files[0])}
size="small"
type="file"
required
sx={{ display: 'none' }}
autoComplete="off"
/>
</>
)}
/>
</Button>
<IconButton color="error" onClick={() => form.setFieldValue('image', undefined)}>
<Delete />
</IconButton>
</Box>
</Grid>
<Grid item xs={12}>
<Divider variant="middle">
<Typography sx={{ opacity: 0.36 }}>{t('or')}</Typography>
</Divider>
</Grid>
<Grid item xs={12}>
<form.Field
name="predefined"
children={(field) => (
<FormControl fullWidth>
<InputLabel size="small">{t('Predefined')}</InputLabel>
<Select
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Predefined')}
autoComplete="off"
fullWidth
>
{[...Array(10).keys()].map((i) => (
<MenuItem key={`avatar-${i + 1}`} value={`avatar-${i + 1}`}>
{t('Avatar', { name: i + 1 })}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit]) => (
<>
<Button
variant="outlined"
onClick={() => {
handleClose();
}}
>
{t('Cancel')}
</Button>
<Button
type="submit"
disabled={!canSubmit || updateMutation.isPending}
autoFocus
variant="contained"
endIcon={updateMutation.isPending && <CircularProgress color="inherit" size="20px" />}
>
{t('Save')}
</Button>
</>
)}
/>
</DialogActions>
{error && (
<DialogContent>
<ErrorComponent error={error} context="userUpdate" />
</DialogContent>
)}
</Dialog>
);
};
export default UserImageDialog;
@@ -1,6 +1,7 @@
import { Box, Button, CircularProgress, LinearProgress, TextField } from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
@@ -14,6 +15,7 @@ const PostForm: FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const newMutation = useMutation({
mutationFn: ({ data }: { data: PostUpdate }) => {
@@ -30,8 +32,10 @@ const PostForm: FC = () => {
newMutation.mutate(
{ data: value },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
onSuccess: async (data) => {
form.reset();
await queryClient.invalidateQueries({ queryKey: ['posts'] });
navigate({ to: '/', search: { page: data.pages - 1 } });
},
onError: setError,
}
+6 -5
View File
@@ -17,7 +17,7 @@ import {
Typography,
} from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Link, useNavigate } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
@@ -42,9 +42,9 @@ const Post: FC<Props> = ({ post }) => {
},
});
const queryClient = useQueryClient();
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
return (
<Card>
@@ -121,10 +121,11 @@ const Post: FC<Props> = ({ post }) => {
color="error"
onClick={() => {
deleteMutation.mutate(post.id, {
onSuccess: () => {
queryClient.invalidateQueries({
onSuccess: async (data) => {
await queryClient.invalidateQueries({
queryKey: ['posts'],
});
navigate({ to: '/', search: { page: data.pages - 1 } });
},
onError: setError,
});
+26 -8
View File
@@ -1,10 +1,22 @@
import { Person } from '@mui/icons-material';
import { Avatar, Box, Button, Card, CardActions, CardContent, Divider, Grid, Typography } from '@mui/material';
import {
Avatar,
Box,
Button,
Card,
CardActions,
CardContent,
Divider,
Grid,
IconButton,
Typography,
} from '@mui/material';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '../../types/User';
import convertDate from '../../utils/date';
import UserEditDialog from '../Dialogs/UserEdit/UserEditDialog';
import UserImageDialog from '../Dialogs/UserImage/UserImageDialog';
interface Props {
user: User;
@@ -13,6 +25,7 @@ interface Props {
const Profile: FC<Props> = ({ user, canEdit }) => {
const [editOpen, setEditOpen] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const { t } = useTranslation();
@@ -23,9 +36,11 @@ const Profile: FC<Props> = ({ user, canEdit }) => {
<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>
<IconButton onClick={() => setImageOpen(true)}>
<Avatar alt={user.username} src={`storage/>${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Person sx={{ width: '60px', height: '60px' }} />
</Avatar>
</IconButton>
</Grid>
<Grid item sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', columnGap: 1 }}>
@@ -43,12 +58,15 @@ const Profile: FC<Props> = ({ user, canEdit }) => {
</CardContent>
<CardActions>
{canEdit && (
<Button size="small" onClick={() => setEditOpen(true)}>
{t('Edit')}
</Button>
<>
<Button size="small" onClick={() => setEditOpen(true)}>
{t('Edit')}
</Button>
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
<UserImageDialog user={user} open={imageOpen} onClose={() => setImageOpen(false)} />
</>
)}
</CardActions>
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
</Card>
</Grid>
<Grid item xs={12}>
+9 -1
View File
@@ -1,6 +1,7 @@
import { Divider, Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import PostForm from '../components/Forms/Post/PostForm';
@@ -12,6 +13,13 @@ const Home = () => {
const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
if ((page ?? 0) >= postsQuery.pages) {
navigate({ to: '/', search: { page: postsQuery.pages - 1 } });
}
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
return (
<>
+6
View File
@@ -23,6 +23,12 @@ export interface PostAuth {
postedAt: Timestamp;
}
export interface PostNew {
pages: number;
post: PostAuth;
}
export interface PostDelete extends PostNew {}
export interface PostListAuth {
pages: number;
data: PostNonAuth[];
+5
View File
@@ -21,3 +21,8 @@ export interface Login {
email: string;
password: string;
}
export interface UserImageUpdate {
image?: File;
predefined?: string;
}