diff --git a/src/components/Account/UpdateProfileForm.tsx b/src/components/Account/UpdateProfileForm.tsx new file mode 100644 index 0000000..87ea529 --- /dev/null +++ b/src/components/Account/UpdateProfileForm.tsx @@ -0,0 +1,93 @@ +import FormError from '@/components/ui/forms/FormError'; +import FormInfo from '@/components/ui/forms/FormInfo'; +import FormLabel from '@/components/ui/forms/FormLabel'; +import FormSegment from '@/components/ui/forms/FormSegment'; +import Link from 'next/link'; +import FormTextArea from '@/components/ui/forms/FormTextArea'; +import { FC } from 'react'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import type { + UseFormHandleSubmit, + SubmitHandler, + FieldErrors, + UseFormRegister, +} from 'react-hook-form'; +import { z } from 'zod'; +import UpdateProfileSchema from '../../services/User/schema/UpdateProfileSchema'; + +type UpdateProfileSchemaT = z.infer; + +interface UpdateProfileFormProps { + handleSubmit: UseFormHandleSubmit; + onSubmit: SubmitHandler; + errors: FieldErrors; + isSubmitting: boolean; + register: UseFormRegister; + user: z.infer; +} + +const UpdateProfileForm: FC = ({ + handleSubmit, + onSubmit, + errors, + isSubmitting, + register, + user, +}) => { + return ( +
+
+ + Avatar + {errors.userAvatar?.message} + + + + +
+
+ + Bio + {errors.bio?.message} + + + + + +
+
+ + Cancel Changes + + + +
+
+ ); +}; + +export default UpdateProfileForm; diff --git a/src/components/Account/UpdateProfileLink.tsx b/src/components/Account/UpdateProfileLink.tsx new file mode 100644 index 0000000..e8c941a --- /dev/null +++ b/src/components/Account/UpdateProfileLink.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import React from 'react'; + +import { FaArrowRight } from 'react-icons/fa'; + +const UpdateProfileLink: React.FC = () => { + return ( +
+
+
+
+

Update Your Profile

+

You can update your profile information here.

+
+
+ + + +
+
+
+
+ ); +}; + +export default UpdateProfileLink; diff --git a/src/components/BreweryIndex/BreweryCard.tsx b/src/components/BreweryIndex/BreweryCard.tsx index bf5b076..ff1402f 100644 --- a/src/components/BreweryIndex/BreweryCard.tsx +++ b/src/components/BreweryIndex/BreweryCard.tsx @@ -22,7 +22,7 @@ const BreweryCard: FC<{ brewery: z.infer }> = ({ src={brewery.breweryImages[0].path} alt={brewery.name} width="1029" - height="110" + height="1029" crop="fill" className="h-full object-cover" /> diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index 24aa411..7715b41 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -31,50 +31,52 @@ const UserHeader: FC = ({ user }) => { return (
-
+

{user.username}

+ +
+ {followingCount} Following + {followerCount} Followers +
+
+
+ + joined{' '} + {timeDistance && ( + + {`${timeDistance} ago`} + + )} +
-
- {followingCount} Following - {followerCount} Followers -
+ {user.bio && ( +
+

{user.bio}

+
+ )} - - joined{' '} - {timeDistance && ( - - {`${timeDistance} ago`} - - )} - -
-

{user.bio}

-
- - {currentUser?.id !== user.id ? ( -
+
+ {currentUser?.id !== user.id ? ( -
- ) : ( -
- + ) : ( + Edit Profile -
- )} + )} +
); diff --git a/src/config/nextConnect/middleware/checkIfBeerPostOwner.ts b/src/config/nextConnect/middleware/checkIfBeerPostOwner.ts deleted file mode 100644 index f5e1169..0000000 --- a/src/config/nextConnect/middleware/checkIfBeerPostOwner.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { UserExtendedNextApiRequest } from '@/config/auth/types'; -import ServerError from '@/config/util/ServerError'; -import getBeerPostById from '@/services/BeerPost/getBeerPostById'; -import { NextApiResponse } from 'next'; -import { NextHandler } from 'next-connect'; - -interface CheckIfBeerPostOwnerRequest extends UserExtendedNextApiRequest { - query: { id: string }; -} - -const checkIfBeerPostOwner = async ( - req: RequestType, - res: NextApiResponse, - next: NextHandler, -) => { - const { id } = req.query; - const user = req.user!; - const beerPost = await getBeerPostById(id); - - if (!beerPost) { - throw new ServerError('Beer post not found', 404); - } - - if (beerPost.postedBy.id !== user.id) { - throw new ServerError('You are not authorized to edit this beer post', 403); - } - - return next(); -}; - -export default checkIfBeerPostOwner; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts index a1766a7..dce2bfa 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts @@ -1,16 +1,38 @@ +/** + * Custom hook using SWR for fetching users followed by a specific user. + * + * @param options - The options for fetching users. + * @param [options.pageSize=5] - The number of users to fetch per page. Default is `5` + * @param options.userId - The ID of the user. + * @returns An object with the following properties: + * + * - `following` The list of users followed by the specified user. + * - `followingCount` The total count of users followed by the specified user. + * - `pageCount` The total number of pages. + * - `size` The current page size. + * - `setSize` A function to set the page size. + * - `isLoading` Indicates if the data is currently being loaded. + * - `isLoadingMore` Indicates if there are more pages to load. + * - `isAtEnd` Indicates if the current page is the last page. + * - `mutate` A function to mutate the data. + * - `error` The error object, if any. + */ import FollowInfoSchema from '@/services/UserFollows/schema/FollowInfoSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import useSWRInfinite from 'swr/infinite'; import { z } from 'zod'; const useGetUsersFollowedByUser = ({ - pageSize, + pageSize = 5, userId, }: { - pageSize: number; - userId: string; + pageSize?: number; + userId: string | undefined; }) => { const fetcher = async (url: string) => { + if (!userId) { + throw new Error('User ID is undefined'); + } const response = await fetch(url); if (!response.ok) { throw new Error(response.statusText); diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts index a92d493..fb3eb3d 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -3,14 +3,40 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem import useSWRInfinite from 'swr/infinite'; import { z } from 'zod'; -const useGetUsersFollowingUser = ({ - pageSize, - userId, -}: { - pageSize: number; - userId: string; -}) => { +interface UseGetUsersFollowingUser { + pageSize?: number; + userId?: string; +} + +/** + * Custom hook using SWR for fetching users followed by a specific user. + * + * @example + * const { followers, followerCount } = useGetUsersFollowingUser({ userId: '123' }); + * + * @param options - The options for fetching users. + * @param [options.pageSize=5] - The number of users to fetch per page. Default is `5` + * @param options.userId - The ID of the user. + * @returns An object with the following properties: + * + * - `followers` The list of users following the specified user. + * - `followerCount` The total count of users following the specified user. + * - `pageCount` The total number of pages. + * - `size` The current page size. + * - `setSize` A function to set the page size. + * - `isLoading` Indicates if the data is currently being loaded. + * - `isLoadingMore` Indicates if there are more pages to load. + * - `isAtEnd` Indicates if the current page is the last page. + * - `mutate` A function to mutate the data. + * - `error` The error object, if any. + */ + +const useGetUsersFollowingUser = ({ pageSize = 5, userId }: UseGetUsersFollowingUser) => { const fetcher = async (url: string) => { + if (!userId) { + throw new Error('User ID is undefined'); + } + const response = await fetch(url); if (!response.ok) { throw new Error(response.statusText); diff --git a/src/hooks/utilities/useNavbar.ts b/src/hooks/utilities/useNavbar.ts index 184777e..586d5e9 100644 --- a/src/hooks/utilities/useNavbar.ts +++ b/src/hooks/utilities/useNavbar.ts @@ -23,7 +23,8 @@ const useNavbar = () => { const { user } = useContext(UserContext); const authenticatedPages: readonly Page[] = [ - { slug: '/account', name: 'Account' }, + { slug: '/users/account', name: 'Account' }, + { slug: `/users/${user?.id}`, name: 'Profile' }, { slug: '/api/users/logout', name: 'Logout' }, ]; @@ -35,6 +36,7 @@ const useNavbar = () => { /** These pages are accessible to both authenticated and unauthenticated users. */ const otherPages: readonly Page[] = [ { slug: '/beers', name: 'Beers' }, + { slug: '/beers/styles', name: 'Beer Styles' }, { slug: '/breweries', name: 'Breweries' }, ]; diff --git a/src/pages/account/profile.tsx b/src/pages/account/profile.tsx deleted file mode 100644 index 3df5c96..0000000 --- a/src/pages/account/profile.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import FormError from '@/components/ui/forms/FormError'; -import FormInfo from '@/components/ui/forms/FormInfo'; -import FormLabel from '@/components/ui/forms/FormLabel'; -import FormSegment from '@/components/ui/forms/FormSegment'; -import FormTextInput from '@/components/ui/forms/FormTextInput'; -import findUserById from '@/services/User/findUserById'; -import GetUserSchema from '@/services/User/schema/GetUserSchema'; -import withPageAuthRequired from '@/util/withPageAuthRequired'; -import { GetServerSideProps, NextPage } from 'next'; -import { z } from 'zod'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { SubmitHandler, useForm } from 'react-hook-form'; -import toast from 'react-hot-toast'; -import createErrorToast from '@/util/createErrorToast'; -import Button from '@/components/ui/forms/Button'; - -interface ProfilePageProps { - user: z.infer; -} - -const UpdateProfileSchema = z.object({ - bio: z.string().min(1, 'Bio cannot be empty'), - userAvatar: z - .instanceof(typeof FileList !== 'undefined' ? FileList : Object) - .refine((fileList) => fileList instanceof FileList, { - message: 'You must submit this form in a web browser.', - }) - .refine((fileList) => (fileList as FileList).length === 1, { - message: 'You must upload exactly one file.', - }) - .refine( - (fileList) => - [...(fileList as FileList)] - .map((file) => file.type) - .every((fileType) => fileType.startsWith('image/')), - { message: 'You must upload only images.' }, - ) - .refine( - (fileList) => - [...(fileList as FileList)] - .map((file) => file.size) - .every((fileSize) => fileSize < 15 * 1024 * 1024), - { message: 'You must upload images smaller than 15MB.' }, - ), -}); - -const sendUpdateProfileRequest = async (data: z.infer) => { - if (!(data.userAvatar instanceof FileList)) { - throw new Error('You must submit this form in a web browser.'); - } - - const { bio, userAvatar } = data; - - const formData = new FormData(); - formData.append('image', userAvatar[0]); - formData.append('bio', bio); - - const response = await fetch(`/api/users/profile`, { - method: 'PUT', - body: formData, - }); - - if (!response.ok) { - throw new Error('Something went wrong.'); - } - - const updatedUser = await response.json(); - - return updatedUser; -}; - -const ProfilePage: NextPage = ({ user }) => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - reset, - } = useForm>({ - resolver: zodResolver(UpdateProfileSchema), - }); - - const onSubmit: SubmitHandler> = async (data) => { - try { - await sendUpdateProfileRequest(data); - const loadingToast = toast.loading('Updating profile...'); - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - toast.remove(loadingToast); - // reset(); - toast.success('Profile updated!'); - } catch (error) { - createErrorToast(error); - } - }; - return ( -
-
-
{JSON.stringify(user, null, 2)}
-
- - Bio - {errors.bio?.message} - - - - - - - - Avatar - {errors.userAvatar?.message} - - - - - -
- -
-
-
-
- ); -}; - -export default ProfilePage; - -export const getServerSideProps: GetServerSideProps = - withPageAuthRequired(async (context, session) => { - const { id } = session; - - const user = await findUserById(id); - - if (!user) { - return { notFound: true }; - } - - return { props: { user: JSON.parse(JSON.stringify(user)) } }; - }); diff --git a/src/pages/api/users/profile.ts b/src/pages/api/users/[id]/profile/index.ts similarity index 82% rename from src/pages/api/users/profile.ts rename to src/pages/api/users/[id]/profile/index.ts index 555c641..9c39a18 100644 --- a/src/pages/api/users/profile.ts +++ b/src/pages/api/users/[id]/profile/index.ts @@ -11,7 +11,7 @@ import { createRouter } from 'next-connect'; import { z } from 'zod'; interface UpdateProfileRequest extends UserExtendedNextApiRequest { - file?: Express.Multer.File; + file: Express.Multer.File; body: { bio: string; }; @@ -30,14 +30,26 @@ interface UpdateUserProfileByIdParams { } const updateUserProfileById = async ({ id, data }: UpdateUserProfileByIdParams) => { - const { alt, path, caption } = data.avatar; const user: z.infer = await DBClient.instance.user.update({ where: { id }, data: { bio: data.bio, - userAvatar: { - upsert: { create: { alt, path, caption }, update: { alt, path, caption } }, - }, + userAvatar: data.avatar + ? { + upsert: { + create: { + alt: data.avatar.alt, + path: data.avatar.path, + caption: data.avatar.caption, + }, + update: { + alt: data.avatar.alt, + path: data.avatar.path, + caption: data.avatar.caption, + }, + }, + } + : undefined, }, select: { id: true, @@ -61,10 +73,6 @@ const updateUserProfileById = async ({ id, data }: UpdateUserProfileByIdParams) const updateProfile = async (req: UpdateProfileRequest, res: NextApiResponse) => { const { file, body, user } = req; - if (!file) { - throw new Error('No file uploaded'); - } - await updateUserProfileById({ id: user!.id, data: { @@ -86,10 +94,11 @@ const router = createRouter< router.put( getCurrentUser, + // @ts-expect-error singleUploadMiddleware, - validateRequest({ bodySchema: z.object({ bio: z.string().max(1000) }) }), + updateProfile, ); diff --git a/src/pages/api/users/[id]/profile/update-avatar.ts b/src/pages/api/users/[id]/profile/update-avatar.ts new file mode 100644 index 0000000..be6846b --- /dev/null +++ b/src/pages/api/users/[id]/profile/update-avatar.ts @@ -0,0 +1,118 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import { singleUploadMiddleware } from '@/config/multer/uploadMiddleware'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; + +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { NextHandler, createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface UpdateProfileRequest extends UserExtendedNextApiRequest { + file: Express.Multer.File; + body: { + bio: string; + }; +} + +interface UpdateUserProfileByIdParams { + id: string; + data: { + avatar: { + alt: string; + path: string; + caption: string; + }; + }; +} + +const updateUserAvatarById = async ({ id, data }: UpdateUserProfileByIdParams) => { + const user: z.infer = await DBClient.instance.user.update({ + where: { id }, + data: { + userAvatar: data.avatar + ? { + upsert: { + create: { + alt: data.avatar.alt, + path: data.avatar.path, + caption: data.avatar.caption, + }, + update: { + alt: data.avatar.alt, + path: data.avatar.path, + caption: data.avatar.caption, + }, + }, + } + : undefined, + }, + select: { + id: true, + username: true, + email: true, + bio: true, + userAvatar: true, + accountIsVerified: true, + createdAt: true, + firstName: true, + lastName: true, + updatedAt: true, + dateOfBirth: true, + role: true, + }, + }); + + return user; +}; + +const checkIfUserCanUpdateProfile = async ( + req: UpdateProfileRequest, + res: NextApiResponse, + next: NextHandler, +) => { + const user = req.user!; + + if (user.id !== req.query.id) { + throw new ServerError('You can only update your own profile.', 403); + } + + await next(); +}; + +const updateProfile = async (req: UpdateProfileRequest, res: NextApiResponse) => { + const { file, user } = req; + + await updateUserAvatarById({ + id: user!.id, + data: { + avatar: { alt: file.originalname, path: file.path, caption: '' }, + }, + }); + res.status(200).json({ + message: 'User avatar updated successfully.', + statusCode: 200, + success: true, + }); +}; + +const router = createRouter< + UpdateProfileRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + checkIfUserCanUpdateProfile, + // @ts-expect-error + singleUploadMiddleware, + updateProfile, +); + +const handler = router.handler(); + +export default handler; +export const config = { api: { bodyParser: false } }; diff --git a/src/pages/api/users/[id]/profile/update-bio.ts b/src/pages/api/users/[id]/profile/update-bio.ts new file mode 100644 index 0000000..c75b91d --- /dev/null +++ b/src/pages/api/users/[id]/profile/update-bio.ts @@ -0,0 +1,89 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; + +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { NextHandler, createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface UpdateProfileRequest extends UserExtendedNextApiRequest { + body: { + bio: string; + }; +} + +interface UpdateUserProfileByIdParams { + id: string; + data: { bio: string }; +} + +const updateUserProfileById = async ({ id, data }: UpdateUserProfileByIdParams) => { + const user: z.infer = await DBClient.instance.user.update({ + where: { id }, + data: { bio: data.bio }, + select: { + id: true, + username: true, + email: true, + bio: true, + userAvatar: true, + accountIsVerified: true, + createdAt: true, + firstName: true, + lastName: true, + updatedAt: true, + dateOfBirth: true, + role: true, + }, + }); + + return user; +}; + +const updateProfile = async (req: UpdateProfileRequest, res: NextApiResponse) => { + const user = req.user!; + const { body } = req; + + await updateUserProfileById({ id: user!.id, data: { bio: body.bio } }); + + res.status(200).json({ + message: 'Profile updated successfully.', + statusCode: 200, + success: true, + }); +}; + +const checkIfUserCanUpdateProfile = async ( + req: UpdateProfileRequest, + res: NextApiResponse, + next: NextHandler, +) => { + const user = req.user!; + + if (user.id !== req.query.id) { + throw new ServerError('You can only update your own profile.', 403); + } + + await next(); +}; + +const router = createRouter< + UpdateProfileRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + checkIfUserCanUpdateProfile, + validateRequest({ bodySchema: z.object({ bio: z.string().max(1000) }) }), + updateProfile, +); + +const handler = router.handler(); + +export default handler; diff --git a/src/pages/users/account/edit-profile.tsx b/src/pages/users/account/edit-profile.tsx new file mode 100644 index 0000000..bb04dad --- /dev/null +++ b/src/pages/users/account/edit-profile.tsx @@ -0,0 +1,181 @@ +import { useContext, useEffect } from 'react'; + +import { GetServerSideProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; + +import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import toast from 'react-hot-toast'; + +import withPageAuthRequired from '@/util/withPageAuthRequired'; +import createErrorToast from '@/util/createErrorToast'; + +import UserContext from '@/contexts/UserContext'; + +import UserAvatar from '@/components/Account/UserAvatar'; +import UpdateProfileForm from '@/components/Account/UpdateProfileForm'; + +import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; +import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; + +import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; +import sendUpdateUserAvatarRequest from '@/requests/Account/sendUpdateUserAvatarRequest'; +import sendUpdateUserProfileRequest from '@/requests/Account/sendUpdateUserProfileRequest.ts'; +import Spinner from '@/components/ui/Spinner'; + +const ProfilePage: NextPage = () => { + const { user, mutate: mutateUser } = useContext(UserContext); + + const { + register, + handleSubmit, + setValue, + formState: { errors, isSubmitting }, + watch, + } = useForm>({ + resolver: zodResolver(UpdateProfileSchema), + }); + + useEffect(() => { + if (!user || !user.bio) { + return; + } + + setValue('bio', user.bio); + }, [user, setValue]); + + const router = useRouter(); + + const onSubmit: SubmitHandler> = async (data) => { + try { + const loadingToast = toast.loading('Updating profile...'); + if (!user) { + throw new Error('User is not logged in.'); + } + + if (data.userAvatar instanceof FileList && data.userAvatar.length === 1) { + await sendUpdateUserAvatarRequest({ + userId: user.id, + file: data.userAvatar[0], + }); + } + + await sendUpdateUserProfileRequest({ + userId: user.id, + bio: data.bio, + }); + + toast.remove(loadingToast); + await mutateUser!(); + await router.push(`/users/${user.id}`); + toast.success('Profile updated.'); + } catch (error) { + createErrorToast(error); + } + }; + const { followingCount } = useGetUsersFollowedByUser({ + userId: user?.id, + }); + + const { followerCount } = useGetUsersFollowingUser({ + userId: user?.id, + }); + + const getUserAvatarPath = () => { + const watchedInput = watch('userAvatar'); + + if ( + !( + watchedInput instanceof FileList && + watchedInput.length === 1 && + watchedInput[0].type.startsWith('image/') + ) + ) { + return ''; + } + + const [avatar] = watchedInput; + + return URL.createObjectURL(avatar); + }; + + return ( + <> + + The Biergarten App || Update Your Profile + + +
+ {user ? ( +
+
+
+
+
+ +
+
+

+ {user.username} +

+ +
+ {followingCount} Following + {followerCount} Followers +
+
+ +
+

+ {watch('bio') ? ( + {watch('bio')} + ) : ( + Your bio will appear here. + )} +

+
+
+ + +
+
+
+ ) : ( +
+ +
+ )} +
+ + ); +}; + +export default ProfilePage; + +export const getServerSideProps: GetServerSideProps = withPageAuthRequired(); diff --git a/src/pages/account/index.tsx b/src/pages/users/account/index.tsx similarity index 96% rename from src/pages/account/index.tsx rename to src/pages/users/account/index.tsx index d5cc046..fe42459 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/users/account/index.tsx @@ -11,6 +11,7 @@ import DeleteAccount from '@/components/Account/DeleteAccount'; import accountPageReducer from '@/reducers/accountPageReducer'; import UserAvatar from '@/components/Account/UserAvatar'; import UserPosts from '@/components/Account/UserPosts'; +import UpdateProfileLink from '@/components/Account/UpdateProfileLink'; const AccountPage: NextPage = () => { const { user } = useContext(UserContext); @@ -57,6 +58,7 @@ const AccountPage: NextPage = () => { + diff --git a/src/requests/Account/sendUpdateUserAvatarRequest.ts b/src/requests/Account/sendUpdateUserAvatarRequest.ts new file mode 100644 index 0000000..8ce2c6c --- /dev/null +++ b/src/requests/Account/sendUpdateUserAvatarRequest.ts @@ -0,0 +1,27 @@ +interface UpdateProfileRequestParams { + file: File; + userId: string; +} + +const sendUpdateUserAvatarRequest = async ({ + file, + userId, +}: UpdateProfileRequestParams) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch(`/api/users/${userId}/`, { + method: 'PUT', + body: formData, + }); + + if (!response.ok) { + throw new Error('Failed to update profile.'); + } + + const json = await response.json(); + + return json; +}; + +export default sendUpdateUserAvatarRequest; diff --git a/src/requests/Account/sendUpdateUserProfileRequest.ts.ts b/src/requests/Account/sendUpdateUserProfileRequest.ts.ts new file mode 100644 index 0000000..8d421d6 --- /dev/null +++ b/src/requests/Account/sendUpdateUserProfileRequest.ts.ts @@ -0,0 +1,28 @@ +import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; +import { z } from 'zod'; + +interface UpdateProfileRequestParams { + userId: string; + bio: z.infer['bio']; +} + +const sendUpdateUserProfileRequest = async ({ + bio, + userId, +}: UpdateProfileRequestParams) => { + const response = await fetch(`/api/users/${userId}/profile/update-bio`, { + method: 'PUT', + body: JSON.stringify({ bio }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Failed to update profile.'); + } + + const json = await response.json(); + + return json; +}; + +export default sendUpdateUserProfileRequest; diff --git a/src/services/User/schema/UpdateProfileSchema.ts b/src/services/User/schema/UpdateProfileSchema.ts new file mode 100644 index 0000000..b8face2 --- /dev/null +++ b/src/services/User/schema/UpdateProfileSchema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const UpdateProfileSchema = z.object({ + bio: z.string(), + userAvatar: z + .instanceof(typeof FileList !== 'undefined' ? FileList : Object) + .refine((fileList) => fileList instanceof FileList, { + message: 'You must submit this form in a web browser.', + }) + .refine((fileList) => [...(fileList as FileList)].length <= 1, { + message: 'You must upload only one or zero files.', + }) + .refine( + (fileList) => + [...(fileList as FileList)] + .map((file) => file.type) + .every((fileType) => fileType.startsWith('image/')), + { message: 'You must upload only images.' }, + ) + .refine( + (fileList) => + [...(fileList as FileList)] + .map((file) => file.size) + .every((fileSize) => fileSize < 15 * 1024 * 1024), + { message: 'You must upload images smaller than 15MB.' }, + ), +}); + +export default UpdateProfileSchema; diff --git a/tailwind.config.js b/tailwind.config.js index a48705e..bf6fc8b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -17,8 +17,8 @@ const myThemes = { 'base-300': 'hsl(227, 20%, 10%)', }, light: { - primary: 'hsl(180, 15%, 70%)', - secondary: 'hsl(21, 54%, 83%)', + primary: 'hsl(180, 20%, 70%)', + secondary: 'hsl(120, 10%, 70%)', error: 'hsl(4, 87%, 74%)', accent: 'hsl(93, 27%, 73%)', neutral: 'hsl(38, 31%, 91%)',