Profile Image edit

This commit is contained in:
Kilian Hofmann 2024-07-27 21:00:31 +02:00
parent 15ca7e8879
commit 627654e0a7
18 changed files with 368 additions and 38 deletions

1
exam/dist/assets/index-B5V9Za9P.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-CG0WySTu.js"></script>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-B5V9Za9P.js"></script>
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-DXd9vB-a.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-v3E5hT34.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-yMrSYl0u.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-CxHUbSMi.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-xmxrKlZO.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-DJgSTqOl.js">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css">

View File

@ -60,5 +60,16 @@
"Recent posts": "Letzte Posts",
"Post comment": "Beitrag posten"
"Comment": "Beitrag",
"Content required": "Beitrag darf nicht leer sein",
"Post comment": "Beitrag posten",
"Edit Post": "Post editieren",
"Edit image": "Profilbild bearbeiten",
"Predefined": "Vorgefertigt",
"Upload image": "Bild hochladen",
"Upload named": "{{name}} gewählt",
"Avatar": "Avatar {{name}}",
"Remove": "Entfernen",
"or": "oder"
}

View File

@ -61,5 +61,16 @@
"Recent posts": "Last posts",
"Post omment": "Post comment"
"Comment": "Comment",
"Content required": "Content required",
"Post comment": "Post comment",
"Edit post": "Edit post",
"Edit image": "Edit image",
"Predefined": "Predefined",
"Upload image": "Upload image",
"Upload named": "{{name}} chosen",
"Avatar": "Avatar {{name}}",
"Remove": "Remove",
"or": "or"
}

File diff suppressed because one or more lines are too long

View File

@ -60,5 +60,16 @@
"Recent posts": "Letzte Posts",
"Post comment": "Beitrag posten"
"Comment": "Beitrag",
"Content required": "Beitrag darf nicht leer sein",
"Post comment": "Beitrag posten",
"Edit Post": "Post editieren",
"Edit image": "Profilbild bearbeiten",
"Predefined": "Vorgefertigt",
"Upload image": "Bild hochladen",
"Upload named": "{{name}} gewählt",
"Avatar": "Avatar {{name}}",
"Remove": "Entfernen",
"or": "oder"
}

View File

@ -61,5 +61,16 @@
"Recent posts": "Last posts",
"Post omment": "Post comment"
"Comment": "Comment",
"Content required": "Content required",
"Post comment": "Post comment",
"Edit post": "Edit post",
"Edit image": "Edit image",
"Predefined": "Predefined",
"Upload image": "Upload image",
"Upload named": "{{name}} chosen",
"Avatar": "Avatar {{name}}",
"Remove": "Remove",
"or": "or"
}

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',

View File

@ -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;

View File

@ -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,
}

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,
});

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}>

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 (
<>

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[];

View File

@ -21,3 +21,8 @@ export interface Login {
email: string;
password: string;
}
export interface UserImageUpdate {
image?: File;
predefined?: string;
}