Post delete, Profile edit

This commit is contained in:
Kilian Hofmann 2024-07-27 00:54:23 +02:00
parent a515c447e0
commit 581cacb636
33 changed files with 533 additions and 238 deletions

View File

@ -481,7 +481,7 @@ paths:
value: value:
{ {
"code": "FailedUpdate", "code": "FailedUpdate",
"fields": ["username", "email", "password"], "fields": ["username", "password", "image"],
} }
tags: tags:
- User - User

File diff suppressed because one or more lines are too long

View File

@ -107,7 +107,7 @@ class Post implements JsonSerializable
$data $data
); );
return ["pages" => intdiv($count, $limit) + 1, "data" => $list]; return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $list];
} }
/* /*

View File

@ -255,7 +255,7 @@ class User implements JsonSerializable
$data $data
); );
return ["pages" => intdiv($count, $limit) + 1, "data" => $list]; return ["pages" => intdiv($count, $limit + 1), "data" => $list];
} }
/* /*

1
exam/dist/assets/index-CmwTmvyQ.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

125
exam/dist/assets/mui-BZej3Yg3.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

17
exam/dist/assets/tanstack-DeUNQvBN.js vendored Normal file

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" /> <link rel="icon" type="image/svg+xml" href="/phpCourse/exam/dist/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-DRZup-fm.js"></script> <script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-CmwTmvyQ.js"></script>
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-DXd9vB-a.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-DXd9vB-a.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-DQviNP-p.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BZej3Yg3.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-CLt5K1Fy.js"> <link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-DeUNQvBN.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-DJgSTqOl.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/mui-CKDNpdid.css">
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css"> <link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/index-D83Ey19k.css">

View File

@ -2,6 +2,19 @@
"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",
"Unauthorized_userUpdate": "Keine Berechtigung",
"NotFound_user:userUpdate": "Benutzer nicht gefunden",
"FailedUpdate_userUpdate": "{{name}} konnte nicht aktualisiert werden",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
"image": "Bild",
"content": "Beitrag",
"GuestBook": "Gästebuch", "GuestBook": "Gästebuch",
"Email": "E-Mail", "Email": "E-Mail",
@ -9,14 +22,28 @@
"Email required": "E-Mail darf nicht leer sein", "Email required": "E-Mail darf nicht leer sein",
"Password required": "Passwort darf nicht leer sein", "Password required": "Passwort darf nicht leer sein",
"Username required": "Benutzername darf nicht leer sein",
"Log in": "Anmelden", "Log in": "Anmelden",
"Log out": "Abmelden", "Log out": "Abmelden",
"Profile": "Profil", "Profile": "Profil",
"Updating": "Aktualisiert", "Updating": "Aktualisiert...",
"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",
"Save": "Speichern",
"Cancel": "Abbrechen",
"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?",
"Deleting": "Löscht...",
"Edit data": "Daten ändern"
} }

View File

@ -2,6 +2,19 @@
"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",
"Unauthorized_userUpdate": "Unauthorized",
"NotFound_user:userUpdate": "User not found",
"FailedUpdate_userUpdate": "Failed to update {{name}}",
"username": "username",
"email": "email",
"password": "password",
"image": "image",
"content": "content",
"GuestBook": "GuestBook", "GuestBook": "GuestBook",
"Email": "Email", "Email": "Email",
@ -9,14 +22,29 @@
"Email required": "Email required", "Email required": "Email required",
"Password required": "Password required", "Password required": "Password required",
"Username required": "Username required",
"Log in": "Log in", "Log in": "Log in",
"Log out": "Log out", "Log out": "Log out",
"Profile": "Profile", "Profile": "Profile",
"Updating": "Updating", "Updating": "Updating...",
"Username": "Username", "Username": "Username",
"Member since": "Member since", "Member since": "Member since",
"Post count": "Post count" "Post count": "Post count",
"Edit": "Edit",
"Delete": "Delete",
"Save": "Save",
"Cancel": "Cancel",
"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}}?",
"Deleting": "Deleting...",
"Edit data": "Edit date"
} }

File diff suppressed because one or more lines are too long

View File

@ -5,14 +5,12 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended', 'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
], ],
ignorePatterns: ['dist', '.eslintrc.cjs'], ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['react-refresh'], plugins: ['react-refresh'],
rules: { rules: {
'react-refresh/only-export-components': [ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'warn',
{ allowConstantExport: true },
],
}, },
} };

View File

@ -1,9 +1,20 @@
{ {
"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", "Unauthorized_delete": "Keine Berechtigung",
"NotFound_post:delete": "Post nicht gefunden", "NotFound_post:delete": "Post nicht gefunden",
"Unauthorized_userUpdate": "Keine Berechtigung",
"NotFound_user:userUpdate": "Benutzer nicht gefunden",
"FailedUpdate_userUpdate": "{{name}} konnte nicht aktualisiert werden",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
"image": "Bild",
"content": "Beitrag",
"GuestBook": "Gästebuch", "GuestBook": "Gästebuch",
"Email": "E-Mail", "Email": "E-Mail",
@ -11,12 +22,13 @@
"Email required": "E-Mail darf nicht leer sein", "Email required": "E-Mail darf nicht leer sein",
"Password required": "Passwort darf nicht leer sein", "Password required": "Passwort darf nicht leer sein",
"Username required": "Benutzername darf nicht leer sein",
"Log in": "Anmelden", "Log in": "Anmelden",
"Log out": "Abmelden", "Log out": "Abmelden",
"Profile": "Profil", "Profile": "Profil",
"Updating": "Aktualisiert", "Updating": "Aktualisiert...",
"Username": "Benutzername", "Username": "Benutzername",
"Member since": "Mitglied seit", "Member since": "Mitglied seit",
@ -24,9 +36,14 @@
"Edit": "Bearbeiten", "Edit": "Bearbeiten",
"Delete": "Löschen", "Delete": "Löschen",
"Save": "Speichern",
"Cancel": "Abbrechen",
"Yes": "Ja", "Yes": "Ja",
"No": "Nein", "No": "Nein",
"Confirm post delete title": "Diesen Post löschen?", "Confirm post delete title": "Diesen Post löschen?",
"Confirm post delete body": "Möchtest du diesen Post von {{name}} wirklich Löschen?" "Confirm post delete body": "Möchtest du diesen Post von {{name}} wirklich Löschen?",
"Deleting": "Löscht...",
"Edit data": "Daten ändern"
} }

View File

@ -5,6 +5,16 @@
"Unauthorized_delete": "Unauthorized", "Unauthorized_delete": "Unauthorized",
"NotFound_post:delete": "Post not found", "NotFound_post:delete": "Post not found",
"Unauthorized_userUpdate": "Unauthorized",
"NotFound_user:userUpdate": "User not found",
"FailedUpdate_userUpdate": "Failed to update {{name}}",
"username": "username",
"email": "email",
"password": "password",
"image": "image",
"content": "content",
"GuestBook": "GuestBook", "GuestBook": "GuestBook",
"Email": "Email", "Email": "Email",
@ -12,12 +22,13 @@
"Email required": "Email required", "Email required": "Email required",
"Password required": "Password required", "Password required": "Password required",
"Username required": "Username required",
"Log in": "Log in", "Log in": "Log in",
"Log out": "Log out", "Log out": "Log out",
"Profile": "Profile", "Profile": "Profile",
"Updating": "Updating", "Updating": "Updating...",
"Username": "Username", "Username": "Username",
"Member since": "Member since", "Member since": "Member since",
@ -25,9 +36,15 @@
"Edit": "Edit", "Edit": "Edit",
"Delete": "Delete", "Delete": "Delete",
"Save": "Save",
"Cancel": "Cancel",
"Yes": "Yes", "Yes": "Yes",
"No": "No", "No": "No",
"Confirm post delete title": "Delete this post?", "Confirm post delete title": "Delete this post?",
"Confirm post delete body": "Do you really want to delete this post by {{name}}?" "Confirm post delete body": "Do you really want to delete this post by {{name}}?",
"Deleting": "Deleting...",
"Edit data": "Edit date"
} }

View File

@ -1,5 +1,5 @@
import { PostAuth, PostListAuth, PostListNonAuth } from '../types/Post'; import { PostAuth, PostListAuth, PostListNonAuth } from '../types/Post';
import { User } from '../types/User'; import { User, UserUpdate } 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/';
@ -20,6 +20,7 @@ class ApiImpl {
} }
public hasAuth = () => this.token !== undefined; public hasAuth = () => this.token !== undefined;
//FIXME: TESTING
public isAdmin = () => this.hasAuth() && this.self?.isAdmin; public isAdmin = () => this.hasAuth() && this.self?.isAdmin;
public getAuthenticatedUser = () => this.self; public getAuthenticatedUser = () => this.self;
public getCurrentSession = () => [this.token, this.refreshToken]; public getCurrentSession = () => [this.token, this.refreshToken];
@ -57,6 +58,10 @@ class ApiImpl {
return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json(); return await (await this.getAuth(`users/${id ?? this.self?.id}`)).json();
}; };
public updateUser = async (data: UserUpdate, id?: number): Promise<User> => {
return await (await this.patch(`users/${id ?? 'self'}`, data as Record<string, unknown>)).json();
};
/* Internal */ /* Internal */
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => { private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
@ -110,6 +115,17 @@ class ApiImpl {
if (response.ok) return response; if (response.ok) return response;
throw await response.json(); throw await response.json();
}; };
private patch = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
const response = await fetch(`${BASE}${endpoint}`, {
mode: 'cors',
method: 'patch',
headers: { token: this.token ?? '', ...headers },
body: JSON.stringify(body),
});
if (response.ok) return response;
throw await response.json();
};
} }
const Api = new ApiImpl(); const Api = new ApiImpl();

View File

@ -0,0 +1,35 @@
import { Typography } from '@mui/material';
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { ERRORS } from './Errors';
interface Props {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any;
context?: string;
}
const ErrorComponent: FC<Props> = ({ error, context }) => {
const { t } = useTranslation();
if (!error) return null;
if (error.code) {
switch (error.code) {
case ERRORS.NOT_FOUND:
return <Typography color="error.main">{t(error.code, { context: `${error.entity}:${context}` })}</Typography>;
case ERRORS.UNAUTHORIZED:
return <Typography color="error.main">{t(error.code, { context })}</Typography>;
case ERRORS.FAILEDUPDATE:
return error.fields.map((field: string) => (
<Typography key={`error_${field}`} color="error.main">
{t(error.code, { context, name: t(field) })}
</Typography>
));
}
}
return <Typography color="error.main">{t(error?.message ?? 'Unknown', { context })}</Typography>;
};
export default ErrorComponent;

View File

@ -0,0 +1,5 @@
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
FAILEDUPDATE = 'FailedUpdate',
}

View File

@ -1,10 +1,10 @@
import { Box, Button, TextField, Typography } from '@mui/material'; import { Box, Button, TextField } from '@mui/material';
import { useForm } from '@tanstack/react-form'; import { useForm } from '@tanstack/react-form';
import { useRouter } from '@tanstack/react-router'; import { useRouter } from '@tanstack/react-router';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Api from '../../../api/Api'; import Api from '../../../api/Api';
import handleError from '../../../utils/errors'; import ErrorComponent from '../../Error/ErrorComponent';
interface Props { interface Props {
handleClose: () => void; handleClose: () => void;
@ -119,7 +119,7 @@ const LoginForm: FC<Props> = ({ handleClose }) => {
</> </>
)} )}
/> />
{error && <Typography color="error.main">{handleError(error, t, 'login')}</Typography>} {error && <ErrorComponent error={error} context="login" />}
</Box> </Box>
</form> </form>
); );

View File

@ -0,0 +1,160 @@
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
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, UserUpdate } from '../../../types/User';
import ErrorComponent from '../../Error/ErrorComponent';
interface Props {
user: User;
open: boolean;
onClose: () => void;
}
const UserEditDialog: 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: UserUpdate; id?: number }) => {
return Api.updateUser(data, id);
},
});
const form = useForm<UserUpdate>({
defaultValues: {
username: user.username,
},
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 handleClose = () => {
form.reset();
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
fullScreen={fullScreen}
PaperProps={{
component: 'form',
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 data')}</DialogTitle>
<DialogContent>
<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
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
size="small"
label={t('Username')}
required
margin="dense"
autoComplete="new-username"
fullWidth
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
/>
</>
);
}}
/>
</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 UserEditDialog;

View File

@ -16,27 +16,29 @@ import {
Snackbar, Snackbar,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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'; import ErrorComponent from '../Error/ErrorComponent';
interface Props { interface Props {
post: PostNonAuth | PostAuth; post: PostNonAuth | PostAuth;
} }
const Post: FC<Props> = ({ post }) => { const Post: FC<Props> = ({ post }) => {
const [open, setOpen] = useState(false);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => { mutationFn: (id: number) => {
return Api.deletePost(id); return Api.deletePost(id);
}, },
}); });
const [open, setOpen] = useState(false); const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
@ -104,7 +106,13 @@ const Post: FC<Props> = ({ post }) => {
variant="outlined" variant="outlined"
color="error" color="error"
onClick={() => { onClick={() => {
deleteMutation.mutate(post.id); deleteMutation.mutate(post.id, {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts'],
});
},
});
setOpen(false); setOpen(false);
}} }}
> >
@ -117,9 +125,10 @@ const Post: FC<Props> = ({ post }) => {
</CardActions> </CardActions>
<Snackbar open={deleteMutation.isError} autoHideDuration={2000} onClose={() => deleteMutation.reset()}> <Snackbar open={deleteMutation.isError} autoHideDuration={2000} onClose={() => deleteMutation.reset()}>
<Alert severity="error" variant="filled" sx={{ width: '100%' }}> <Alert severity="error" variant="filled" sx={{ width: '100%' }}>
{deleteMutation.isError && handleError(deleteMutation.error, t, 'delete')} {deleteMutation.isError && <ErrorComponent error={deleteMutation.error} context="delete" />}
</Alert> </Alert>
</Snackbar> </Snackbar>
<Snackbar open={deleteMutation.isPending} message={t('Deleting')} />
</Card> </Card>
); );
}; };

View File

@ -1,9 +1,10 @@
import { Person } from '@mui/icons-material'; import { Person } from '@mui/icons-material';
import { Avatar, Box, Button, Card, CardActions, CardContent, Grid, Typography } from '@mui/material'; import { Avatar, Box, Button, Card, CardActions, CardContent, Grid, Typography } from '@mui/material';
import { FC } from 'react'; import { FC, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { User } from '../../types/User'; import { User } from '../../types/User';
import convertDate from '../../utils/date'; import convertDate from '../../utils/date';
import UserEditDialog from '../Forms/UserEdit/UserEditDialog';
interface Props { interface Props {
user: User; user: User;
@ -11,6 +12,8 @@ interface Props {
} }
const Profile: FC<Props> = ({ user, canEdit }) => { const Profile: FC<Props> = ({ user, canEdit }) => {
const [editOpen, setEditOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -36,7 +39,14 @@ const Profile: FC<Props> = ({ user, canEdit }) => {
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
<CardActions>{canEdit && <Button size="small">{t('Edit')}</Button>}</CardActions> <CardActions>
{canEdit && (
<Button size="small" onClick={() => setEditOpen(true)}>
{t('Edit')}
</Button>
)}
</CardActions>
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
</Card> </Card>
); );
}; };

View File

@ -0,0 +1,8 @@
import { queryOptions } from '@tanstack/react-query';
import Api from '../api/Api';
export const postsQueryOptions = (page?: number) =>
queryOptions({
queryKey: ['posts', { page: page ?? 0, hasAuth: Api.hasAuth() }],
queryFn: () => Api.posts(page),
});

View File

@ -0,0 +1,13 @@
import { queryOptions } from '@tanstack/react-query';
import Api from '../api/Api';
export const profileSelfQueryOptions = queryOptions({
queryKey: ['profile'],
queryFn: () => Api.user(),
});
export const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});

View File

@ -4,9 +4,9 @@ import { createRootRouteWithContext, ErrorRouteComponent, Outlet, redirect, useR
import { TanStackRouterDevtools } from '@tanstack/router-devtools'; import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { useEffect } from 'react'; import { useEffect } from 'react';
import Api from '../api/Api'; import Api from '../api/Api';
import { ERRORS } from '../components/Error/Errors';
import Header from '../components/Header/Header'; import Header from '../components/Header/Header';
import { ROUTES } from '../types/Routes'; import { ROUTES } from '../types/Routes';
import { ERRORS } from '../utils/errors';
const Root = () => { const Root = () => {
//TODO: REAUTH HERE //TODO: REAUTH HERE

View File

@ -1,17 +1,11 @@
import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material'; import { Grid, Pagination, PaginationItem, Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router'; import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import Post from '../components/Post/Post'; import Post from '../components/Post/Post';
import { postsQueryOptions } from '../queries/postsQuery';
import { ROUTES } from '../types/Routes'; import { ROUTES } from '../types/Routes';
const postsQueryOptions = (page?: number) =>
queryOptions({
queryKey: ['posts', { page: page ?? 0, hasAuth: Api.hasAuth() }],
queryFn: () => Api.posts(page),
});
const Home = () => { const Home = () => {
const { page } = Route.useSearch(); const { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page)); const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));

View File

@ -1,17 +1,12 @@
import { Snackbar } from '@mui/material'; import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next'; import { t } from 'i18next';
import Api from '../../api/Api'; import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile'; import Profile from '../../components/Profile/Profile';
import { profileQueryOptions } from '../../queries/profileQuery';
import { ROUTES } from '../../types/Routes'; import { ROUTES } from '../../types/Routes';
const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});
const ProfilePage = () => { const ProfilePage = () => {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id)); const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id));

View File

@ -1,18 +1,14 @@
import { Snackbar } from '@mui/material'; import { Snackbar } from '@mui/material';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { t } from 'i18next'; import { t } from 'i18next';
import Api from '../../api/Api'; import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile'; import Profile from '../../components/Profile/Profile';
import { profileSelfQueryOptions } from '../../queries/profileQuery';
import { ROUTES } from '../../types/Routes'; import { ROUTES } from '../../types/Routes';
const profileQueryOptions = queryOptions({
queryKey: ['profile'],
queryFn: () => Api.user(),
});
const ProfilePage = () => { const ProfilePage = () => {
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions); const { data: profileQuery, isFetching } = useSuspenseQuery(profileSelfQueryOptions);
return ( return (
<> <>
@ -23,7 +19,7 @@ const ProfilePage = () => {
}; };
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({ export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileQueryOptions), loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(profileSelfQueryOptions),
beforeLoad: () => { beforeLoad: () => {
if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX }); if (!Api.hasAuth()) throw redirect({ to: ROUTES.INDEX });
}, },

View File

@ -10,3 +10,9 @@ export interface User {
memberSince: Timestamp; memberSince: Timestamp;
postCount: number; postCount: number;
} }
export interface UserUpdate {
username?: string;
email?: string;
password?: string;
}

View File

@ -1,39 +0,0 @@
import { TFunction } from 'i18next';
export enum ERRORS {
NOT_FOUND = 'NotFound',
UNAUTHORIZED = 'Unauthorized',
}
/**
* Return translated error
* @param error Error object
* @param context Optional context
* @param t Optional translation function, defautls to pass through
* @returns Translated error or inputs if t as unspecified
*/
const handleError = (
//eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
//eslint-disable-next-line @typescript-eslint/no-explicit-any
t: TFunction<'translation', undefined> | ((..._in: any) => any) = (..._in: any) => _in,
context?: string
): string => {
console.log(context);
if (!error) return t('', {});
if (error.code) {
switch (error.code) {
case ERRORS.NOT_FOUND:
return t(error.code, { context: `${error.entity}:${context}` });
case ERRORS.UNAUTHORIZED:
return t(error.code, { context });
default:
return t('Unknown', { context });
}
}
return t(error?.message ?? 'Unknown', { context });
};
export default handleError;