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:
{
"code": "FailedUpdate",
"fields": ["username", "email", "password"],
"fields": ["username", "password", "image"],
}
tags:
- User

File diff suppressed because one or more lines are too long

View File

@ -107,7 +107,7 @@ class Post implements JsonSerializable
$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
);
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" />
<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-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/mui-DQviNP-p.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-CLt5K1Fy.js">
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BZej3Yg3.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="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.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",
"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",
"Email": "E-Mail",
@ -9,14 +22,28 @@
"Email required": "E-Mail darf nicht leer sein",
"Password required": "Passwort darf nicht leer sein",
"Username required": "Benutzername darf nicht leer sein",
"Log in": "Anmelden",
"Log out": "Abmelden",
"Profile": "Profil",
"Updating": "Aktualisiert",
"Updating": "Aktualisiert...",
"Username": "Benutzername",
"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",
"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",
"Email": "Email",
@ -9,14 +22,29 @@
"Email required": "Email required",
"Password required": "Password required",
"Username required": "Username required",
"Log in": "Log in",
"Log out": "Log out",
"Profile": "Profile",
"Updating": "Updating",
"Updating": "Updating...",
"Username": "Username",
"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',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
};

View File

@ -1,9 +1,20 @@
{
"NotFound_user:login": "Benutzer existiert nicht",
"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",
"Email": "E-Mail",
@ -11,12 +22,13 @@
"Email required": "E-Mail darf nicht leer sein",
"Password required": "Passwort darf nicht leer sein",
"Username required": "Benutzername darf nicht leer sein",
"Log in": "Anmelden",
"Log out": "Abmelden",
"Profile": "Profil",
"Updating": "Aktualisiert",
"Updating": "Aktualisiert...",
"Username": "Benutzername",
"Member since": "Mitglied seit",
@ -24,9 +36,14 @@
"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?"
"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",
"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",
"Email": "Email",
@ -12,12 +22,13 @@
"Email required": "Email required",
"Password required": "Password required",
"Username required": "Username required",
"Log in": "Log in",
"Log out": "Log out",
"Profile": "Profile",
"Updating": "Updating",
"Updating": "Updating...",
"Username": "Username",
"Member since": "Member since",
@ -25,9 +36,15 @@
"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}}?"
"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 { User } from '../types/User';
import { User, UserUpdate } from '../types/User';
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
@ -20,6 +20,7 @@ class ApiImpl {
}
public hasAuth = () => this.token !== undefined;
//FIXME: TESTING
public isAdmin = () => this.hasAuth() && this.self?.isAdmin;
public getAuthenticatedUser = () => this.self;
public getCurrentSession = () => [this.token, this.refreshToken];
@ -57,6 +58,10 @@ class ApiImpl {
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 */
private post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
@ -110,6 +115,17 @@ class ApiImpl {
if (response.ok) return response;
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();

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

View File

@ -1,9 +1,10 @@
import { Person } from '@mui/icons-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 { User } from '../../types/User';
import convertDate from '../../utils/date';
import UserEditDialog from '../Forms/UserEdit/UserEditDialog';
interface Props {
user: User;
@ -11,6 +12,8 @@ interface Props {
}
const Profile: FC<Props> = ({ user, canEdit }) => {
const [editOpen, setEditOpen] = useState(false);
const { t } = useTranslation();
return (
@ -36,7 +39,14 @@ const Profile: FC<Props> = ({ user, canEdit }) => {
</Grid>
</Grid>
</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>
);
};

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 { useEffect } from 'react';
import Api from '../api/Api';
import { ERRORS } from '../components/Error/Errors';
import Header from '../components/Header/Header';
import { ROUTES } from '../types/Routes';
import { ERRORS } from '../utils/errors';
const Root = () => {
//TODO: REAUTH HERE

View File

@ -1,17 +1,11 @@
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 { useTranslation } from 'react-i18next';
import Api from '../api/Api';
import Post from '../components/Post/Post';
import { postsQueryOptions } from '../queries/postsQuery';
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 { page } = Route.useSearch();
const { data: postsQuery, isFetching } = useSuspenseQuery(postsQueryOptions(page));

View File

@ -1,17 +1,12 @@
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 { t } from 'i18next';
import Api from '../../api/Api';
import Profile from '../../components/Profile/Profile';
import { profileQueryOptions } from '../../queries/profileQuery';
import { ROUTES } from '../../types/Routes';
const profileQueryOptions = (id?: number) =>
queryOptions({
queryKey: ['profile', { id }],
queryFn: () => Api.user(id),
});
const ProfilePage = () => {
const { id } = Route.useParams();
const { data: profileQuery, isFetching } = useSuspenseQuery(profileQueryOptions(id));

View File

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

View File

@ -10,3 +10,9 @@ export interface User {
memberSince: Timestamp;
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;