New picture link output, register modal

This commit is contained in:
2024-07-28 01:46:16 +02:00
parent 72a0ad6364
commit 215ed1bc7f
30 changed files with 402 additions and 59 deletions
+12 -1
View File
@@ -20,6 +20,8 @@
"Unauthorized_postUpdate": "Keine Berechtigung",
"NotFound_post:postUpdate": "Post nicht gefunden",
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
@@ -71,5 +73,14 @@
"Upload named": "{{name}} gewählt",
"Avatar": "Avatar {{name}}",
"Remove": "Entfernen",
"or": "oder"
"or": "oder",
"Leave comment header": "Du benötigst ein Konto um einen Kommentar zu hinterlassen.",
"Leave comment action": "Klicke oben rechts auf das Icon um dich anzumelden oder zu registrieren.",
"Register prompt": "<0>Noch kein Konto?</0> <1>Registriere</1> <2>dich jetzt!</2>",
"Register": "Konto anlegen",
"Confirm header": "Fast geschafft!",
"Confirm mail": "Prüfe dein E-Mail Postfach auf eine Bestätigungsmail.",
"Close": "Schließen"
}
+12 -1
View File
@@ -20,6 +20,8 @@
"Unauthorized_postUpdate": "Unauthorized",
"NotFound_post:postUpdate": "Post not found",
"Duplicate_user:register": "A user with this username or email already exists",
"username": "username",
"email": "email",
"password": "password",
@@ -72,5 +74,14 @@
"Upload named": "{{name}} chosen",
"Avatar": "Avatar {{name}}",
"Remove": "Remove",
"or": "or"
"or": "or",
"Leave comment header": "You need an account to leave a Comment.",
"Leave comment action": "Click on the icon in the top right corner to log in or register.",
"Register prompt": "<0>No account yet?</0> <1>Register</1> <2>now!</2>",
"Register": "Create account",
"Confirm header": "Almost there!",
"Confirm mail": "Check your email for a confirmation mail.",
"Close": "Close"
}
+8 -4
View File
@@ -1,6 +1,6 @@
import { POST_LIMIT, PROFILE_POST_LIMIT } from '../constanst';
import { PostAuth, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post';
import { User, UserImageUpdate, UserUpdate } from '../types/User';
import { PostAuth, PostCreate, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post';
import { User, UserCreate, UserImageUpdate, UserUpdate } from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@@ -88,8 +88,8 @@ class ApiImpl {
return user;
};
public newPost = async (data: PostUpdate): Promise<PostNew> => {
return await (await this.postAuth(`posts?l=${POST_LIMIT}`, { ...data } as Record<string, unknown>)).json();
public newPost = async (data: PostCreate): Promise<PostNew> => {
return await (await this.postAuth(`posts?l=${POST_LIMIT}`, data as unknown as Record<string, unknown>)).json();
};
public updatePost = async (data: PostUpdate, id: number): Promise<PostAuth> => {
@@ -100,6 +100,10 @@ class ApiImpl {
return await (await this.getAuth(`users/${id}/posts?l=${PROFILE_POST_LIMIT}&s=desc`)).json();
};
public createUser = async (data: UserCreate): Promise<User> => {
return await (await this.post(`register`, data as unknown as Record<string, unknown>)).json();
};
/* Internal */
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
@@ -0,0 +1,243 @@
import { Done } from '@mui/icons-material';
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
TextField,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useForm } from '@tanstack/react-form';
import { useMutation } from '@tanstack/react-query';
import { FC, FormEvent, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import { UserCreate } from '../../../types/User';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
open: boolean;
onClose: () => void;
}
const RegisterDialog: FC<Props> = ({ open, onClose }) => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const [error, setError] = useState<any>();
const createMutation = useMutation({
mutationFn: ({ data }: { data: UserCreate }) => {
return Api.createUser(data);
},
});
const { t } = useTranslation();
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.only('xs'), { noSsr: true });
const form = useForm<UserCreate>({
defaultValues: {
username: '',
email: '',
password: '',
},
onSubmit: async ({ value }) => {
try {
createMutation.mutate(
{ data: value },
{
onSuccess: () => setError(undefined),
onError: setError,
}
);
//eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
setError(error);
}
},
});
const handleClose = () => {
form.reset();
setError(undefined);
onClose();
};
if (createMutation.isSuccess)
return (
<Dialog open={open} onClose={handleClose} fullWidth fullScreen={fullScreen}>
<DialogContent>
<Grid container spacing={2}>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Done color="action" sx={{ fontSize: '200px' }} />
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Typography variant="h5">{t('Confirm header')}</Typography>
</Grid>
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Typography>{t('Confirm mail')}</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>{t('Close')}</Button>
</DialogActions>
</Dialog>
);
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('Register')}</DialogTitle>
<DialogContent sx={{ gap: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<form.Field
name="username"
validators={{
onChange: ({ value }) => (!value ? t('Username required') : undefined),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
return !value && t('Username required');
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Username')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
autoComplete="new-username"
fullWidth
margin="dense"
/>
</>
);
}}
/>
</Grid>
<Grid item xs={12}>
<form.Field
name="email"
validators={{
onChange: ({ value }) => (!value ? t('Email required') : undefined),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
return !value && t('Email required');
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Email')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
type="email"
autoComplete="new-username"
inputMode="email"
fullWidth
/>
</>
);
}}
/>
</Grid>
<Grid item xs={12}>
<form.Field
name="password"
validators={{
onChange: ({ value }) => (!value ? t('Password required') : undefined),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
return !value && t('Password required');
},
}}
children={(field) => {
return (
<>
<TextField
variant="outlined"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Password')}
required
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
type="password"
autoComplete="new-password"
fullWidth
/>
</>
);
}}
/>
</Grid>
<Grid item xs={12}>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit]) => (
<>
<Button
type="submit"
disabled={!canSubmit || createMutation.isPending}
variant="contained"
endIcon={createMutation.isPending && <CircularProgress color="inherit" size="20px" />}
>
{t('Register')}
</Button>
</>
)}
/>
</Grid>
<Grid item xs={12}>
{error && <ErrorComponent error={error} context="register" />}
</Grid>
</Grid>
</DialogContent>
</Dialog>
);
};
export default RegisterDialog;
@@ -25,7 +25,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { FC, FormEvent, useState } from 'react';
import Api from '../../../api/Api';
import { STORAGE_PATH } from '../../../constanst';
import { User, UserImageUpdate } from '../../../types/User';
import ErrorComponent from '../../Error/ErrorComponent';
@@ -110,8 +109,8 @@ const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
formState.image
? URL.createObjectURL(formState.image)
: formState.predefined
? `${STORAGE_PATH}profilbilder/default/${formState.predefined}.svg`
: `${STORAGE_PATH}${user.image}`
? `profilbilder/default/${formState.predefined}.svg`
: `${user.image}`
}
sx={{ width: '100px', height: '100px' }}
>
@@ -13,8 +13,6 @@ interface Props {
const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) => {
const { t } = useTranslation();
console.log(error, context);
if (!error) return null;
if (error.code) {
@@ -35,6 +33,8 @@ const ErrorComponent: FC<Props> = ({ error, context, color = 'error.main' }) =>
{t(error.code, { context: `${field}:${context}` })}
</Typography>
));
case ERRORS.DUPLICATE:
return <Typography color={color}>{t(error.code, { context: `${error.entity}:${context}` })}</Typography>;
}
}
@@ -3,4 +3,5 @@ export enum ERRORS {
UNAUTHORIZED = 'Unauthorized',
FAILED_UPDATE = 'FailedUpdate',
MISSING_FIELD = 'MissingField',
DUPLICATE = 'Duplicate',
}
@@ -5,7 +5,7 @@ import { useNavigate } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api';
import { PostUpdate } from '../../../types/Post';
import { PostCreate } from '../../../types/Post';
import ErrorComponent from '../../Error/ErrorComponent';
const PostForm: FC = () => {
@@ -18,12 +18,12 @@ const PostForm: FC = () => {
const navigate = useNavigate();
const newMutation = useMutation({
mutationFn: ({ data }: { data: PostUpdate }) => {
mutationFn: ({ data }: { data: PostCreate }) => {
return Api.newPost(data);
},
});
const form = useForm<PostUpdate>({
const form = useForm<PostCreate>({
defaultValues: {
content: '',
},
+1 -2
View File
@@ -13,7 +13,6 @@ import { Link, useRouterState } from '@tanstack/react-router';
import { cloneElement, FC, ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import { STORAGE_PATH } from '../../constanst';
import { User } from '../../types/User';
import LanguageMenu from '../Menus/Language/LanguageMenu';
import UserMenu from '../Menus/User/UserMenu';
@@ -62,7 +61,7 @@ const Header: FC = () => {
</IconButton>
{user ? (
<IconButton onClick={(event) => setAnchorUserMenu(event.currentTarget)} sx={{ p: 0 }}>
<Avatar alt={user.username} src={`${STORAGE_PATH}/${user.image}`}>
<Avatar alt={user.username} src={`${user.image}`}>
<Person />
</Avatar>
</IconButton>
@@ -1,9 +1,11 @@
import { Menu, MenuItem } from '@mui/material';
import { Box, Link, Menu, MenuItem, Typography } from '@mui/material';
import { useMatch, useNavigate, useRouter } from '@tanstack/react-router';
import { t } from 'i18next';
import { FC } from 'react';
import { FC, useState } from 'react';
import { Trans } from 'react-i18next/TransWithoutContext';
import Api from '../../../api/Api';
import { ROUTES } from '../../../types/Routes';
import RegisterDialog from '../../Dialogs/Register/RegisterDialog';
import LoginForm from '../../Forms/Login/LoginForm';
interface Props {
@@ -12,12 +14,19 @@ interface Props {
}
const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
const [register, setRegister] = useState(false);
const navigate = useNavigate();
const router = useRouter();
const match = useMatch({ from: '/profile/', strict: true, shouldThrow: false });
const user = Api.getAuthenticatedUser();
const _handleClose = () => {
setRegister(false);
handleClose();
};
return (
<Menu
anchorEl={anchorEl}
@@ -31,7 +40,7 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
horizontal: 'right',
}}
open={Boolean(anchorEl)}
onClose={handleClose}
onClose={_handleClose}
sx={{
'& .MuiMenu-paper': {
minWidth: '240px',
@@ -45,7 +54,7 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
key="profile"
onClick={() => {
navigate({ to: ROUTES.PROFILE });
handleClose();
_handleClose();
}}
>
{t('Profile')}
@@ -55,14 +64,35 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
onClick={async () => {
await Api.logOut();
router.invalidate();
handleClose();
_handleClose();
}}
>
{t('Log out')}
</MenuItem>,
]
) : register ? (
<>
<RegisterDialog open={register} onClose={() => setRegister(false)} />
</>
) : (
<LoginForm handleClose={handleClose} />
<>
<LoginForm handleClose={_handleClose} />
<Box sx={{ padding: 1 }}>
<Trans i18nKey="Register prompt">
<Typography component="span" />
<Link
sx={{ cursor: 'pointer' }}
variant="body1"
underline="hover"
onClick={() => {
setRegister(true);
handleClose();
}}
/>
<Typography component="span" />
</Trans>
</Box>
</>
)}
</Menu>
);
+3 -4
View File
@@ -21,7 +21,6 @@ import { Link, useNavigate } from '@tanstack/react-router';
import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Api from '../../api/Api';
import { STORAGE_PATH } from '../../constanst';
import { PostAuth, PostNonAuth } from '../../types/Post';
import convertDate from '../../utils/date';
import PostEditDialog from '../Dialogs/PostEdit/PostEditDialog';
@@ -55,19 +54,19 @@ const Post: FC<Props> = ({ post, disableActions }) => {
!disableActions && 'id' in post.user ? (
post.user.id !== Api.getAuthenticatedUser()?.id ? (
<MUILink component={Link} to="/profile/$id" params={{ id: post.user.id }}>
<Avatar alt={post.user.username} src={`${STORAGE_PATH}${post.user.image}`}>
<Avatar alt={post.user.username} src={`${post.user.image}`}>
<Person />
</Avatar>
</MUILink>
) : (
<MUILink component={Link} to="/profile">
<Avatar alt={post.user.username} src={`${STORAGE_PATH}${post.user.image}`}>
<Avatar alt={post.user.username} src={`${post.user.image}`}>
<Person />
</Avatar>
</MUILink>
)
) : (
<Avatar alt={post.user.username} src={`${STORAGE_PATH}${post.user.image}`}>
<Avatar alt={post.user.username} src={`${post.user.image}`}>
<Person />
</Avatar>
)
@@ -19,7 +19,6 @@ import convertDate from '../../utils/date';
import UserEditDialog from '../Dialogs/UserEdit/UserEditDialog';
import UserImageDialog from '../Dialogs/UserImage/UserImageDialog';
import Post from '../Post/Post';
import { STORAGE_PATH } from '../../constanst';
interface Props {
user: User;
@@ -41,7 +40,7 @@ const Profile: FC<Props> = ({ user, posts, canEdit }) => {
<Grid container spacing={2}>
<Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}>
<IconButton onClick={() => setImageOpen(true)}>
<Avatar alt={user.username} src={`${STORAGE_PATH}${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Person sx={{ width: '60px', height: '60px' }} />
</Avatar>
</IconButton>
@@ -79,7 +78,7 @@ const Profile: FC<Props> = ({ user, posts, canEdit }) => {
</Divider>
</Grid>
{posts.map((post) => (
<Grid item xs={12}>
<Grid key={`post_${post.id}`} item xs={12}>
<Post post={post} disableActions />
</Grid>
))}
-1
View File
@@ -1,4 +1,3 @@
export const POST_LIMIT = 15;
export const PROFILE_POST_LIMIT = 3;
export const POST_CHAR_LIMIT = 250;
export const STORAGE_PATH = '/phpCourse/exam/storage/';
+23 -6
View File
@@ -1,4 +1,13 @@
import { Divider, Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import {
Divider,
Grid,
Pagination,
PaginationItem,
Snackbar,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react';
@@ -14,6 +23,8 @@ const Home = () => {
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const oneSibling = useMediaQuery(theme.breakpoints.not('xs'), { noSsr: true });
useEffect(() => {
if ((page ?? 0) >= postsQuery.pages) {
@@ -33,15 +44,11 @@ const Home = () => {
<Grid item xs={12}>
<Divider variant="middle" />
</Grid>
{Api.hasAuth() && (
<Grid item xs={12}>
<PostForm />
</Grid>
)}
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
page={(page ?? 0) + 1}
count={postsQuery.pages}
siblingCount={oneSibling ? 1 : 0}
color="primary"
renderItem={(item) => (
<PaginationItem
@@ -55,6 +62,16 @@ const Home = () => {
)}
/>
</Grid>
{Api.hasAuth() ? (
<Grid item xs={12}>
<PostForm />
</Grid>
) : (
<Grid item xs={12}>
<Typography variant="h5">{t('Leave comment header')}</Typography>
<Typography>{t('Leave comment action')}</Typography>
</Grid>
)}
</Grid>
</>
);
-1
View File
@@ -13,7 +13,6 @@ const ProfilePage = () => {
const { data: profilePostsQuery, isFetching: isFetchingPosts } = useSuspenseQuery(
profilePostsQueryOptions(Api.getAuthenticatedUser()?.id ?? 0)
);
console.log(profilePostsQuery.data);
return (
<>
+4
View File
@@ -37,3 +37,7 @@ export interface PostListAuth {
export interface PostUpdate {
content?: string;
}
export interface PostCreate {
content: string;
}
+6
View File
@@ -17,6 +17,12 @@ export interface UserUpdate {
password?: string;
}
export interface UserCreate {
username: string;
email: string;
password: string;
}
export interface Login {
email: string;
password: string;