From d57623e7052bfa0d975f9d498fe8e17cbf65b1e7 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 13:58:17 -0500 Subject: [PATCH 1/5] Additional work on user profile edit --- src/components/Account/UpdateProfileForm.tsx | 93 ++++++++ src/components/UserPage/UserHeader.tsx | 50 ++-- .../user-follows/useGetUsersFollowedByUser.ts | 30 ++- .../user-follows/useGetUsersFollowingUser.ts | 36 ++- src/hooks/utilities/useNavbar.ts | 1 + src/pages/account/profile.tsx | 217 +++++++++--------- src/pages/api/users/profile.ts | 29 ++- .../Account/sendUpdateProfileRequest.tsx | 32 +++ .../User/schema/UpdateProfileSchema.tsx | 28 +++ tailwind.config.js | 4 +- 10 files changed, 361 insertions(+), 159 deletions(-) create mode 100644 src/components/Account/UpdateProfileForm.tsx create mode 100644 src/requests/Account/sendUpdateProfileRequest.tsx create mode 100644 src/services/User/schema/UpdateProfileSchema.tsx 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/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index 24aa411..b5280c4 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -31,50 +31,50 @@ const UserHeader: FC = ({ user }) => { return (
-
+

{user.username}

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

{user.bio}

- {currentUser?.id !== user.id ? ( -
+
+ {currentUser?.id !== user.id ? ( -
- ) : ( -
+ ) : ( Edit Profile -
- )} + )} +
); diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts index a1766a7..c8df097 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) => { + const fetcher = async (url: string | undefined) => { + if (!url) { + throw new Error('URL 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..a5ab521 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -3,13 +3,35 @@ 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) => { const response = await fetch(url); if (!response.ok) { diff --git a/src/hooks/utilities/useNavbar.ts b/src/hooks/utilities/useNavbar.ts index 184777e..924cb39 100644 --- a/src/hooks/utilities/useNavbar.ts +++ b/src/hooks/utilities/useNavbar.ts @@ -24,6 +24,7 @@ const useNavbar = () => { const authenticatedPages: readonly Page[] = [ { slug: '/account', name: 'Account' }, + { slug: `/users/${user?.id}`, name: 'Profile' }, { slug: '/api/users/logout', name: 'Logout' }, ]; diff --git a/src/pages/account/profile.tsx b/src/pages/account/profile.tsx index 3df5c96..44057ff 100644 --- a/src/pages/account/profile.tsx +++ b/src/pages/account/profile.tsx @@ -1,10 +1,3 @@ -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'; @@ -13,74 +6,42 @@ 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; -} +import UserAvatar from '@/components/Account/UserAvatar'; +import { useContext, useEffect } from 'react'; +import UserContext from '@/contexts/UserContext'; -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.' }, - ), -}); +import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; +import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; +import Head from 'next/head'; -const sendUpdateProfileRequest = async (data: z.infer) => { - if (!(data.userAvatar instanceof FileList)) { - throw new Error('You must submit this form in a web browser.'); - } +import UpdateProfileSchema from '../../services/User/schema/UpdateProfileSchema'; +import sendUpdateProfileRequest from '../../requests/Account/sendUpdateProfileRequest'; +import UpdateProfileForm from '../../components/Account/UpdateProfileForm'; - const { bio, userAvatar } = data; +const ProfilePage: NextPage = () => { + const { user, mutate: mutateUser } = useContext(UserContext); - 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, + setValue, formState: { errors, isSubmitting }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + watch, + reset, } = useForm>({ resolver: zodResolver(UpdateProfileSchema), + defaultValues: { + bio: user?.bio ?? '', + }, }); + useEffect(() => { + if (!user || !user.bio) return; + setValue('bio', user.bio); + }, [user, setValue]); + const onSubmit: SubmitHandler> = async (data) => { try { await sendUpdateProfileRequest(data); @@ -89,69 +50,103 @@ const ProfilePage: NextPage = ({ user }) => { setTimeout(resolve, 1000); }); toast.remove(loadingToast); - // reset(); + reset(); + mutateUser!(); 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 ( -
-
-
{JSON.stringify(user, null, 2)}
-
- - Bio - {errors.bio?.message} - + <> + + The Biergarten App || Update Your Profile + + +
+ {user && ( +
+
+
+
+
+ +
+
+

{user.username}

- - - +
+ {followingCount} Following + {followerCount} Followers +
+
- - Avatar - {errors.userAvatar?.message} - - - - +
+

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

+
+
-
- + +
+
- + )}
-
+ ); }; 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)) } }; - }); +export const getServerSideProps: GetServerSideProps = withPageAuthRequired(); diff --git a/src/pages/api/users/profile.ts b/src/pages/api/users/profile.ts index 555c641..9c39a18 100644 --- a/src/pages/api/users/profile.ts +++ b/src/pages/api/users/profile.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/requests/Account/sendUpdateProfileRequest.tsx b/src/requests/Account/sendUpdateProfileRequest.tsx new file mode 100644 index 0000000..1a6b86b --- /dev/null +++ b/src/requests/Account/sendUpdateProfileRequest.tsx @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; + +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(); + if (userAvatar[0]) { + 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; +}; + +export default sendUpdateProfileRequest; diff --git a/src/services/User/schema/UpdateProfileSchema.tsx b/src/services/User/schema/UpdateProfileSchema.tsx new file mode 100644 index 0000000..9e8b33e --- /dev/null +++ b/src/services/User/schema/UpdateProfileSchema.tsx @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const UpdateProfileSchema = z.object({ + bio: z.string().min(1, 'Bio cannot be empty'), + userAvatar: z.optional( + 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)] + .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%)', From 415f1b6a30672386594ae11af297112e28325f51 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 14:09:33 -0500 Subject: [PATCH 2/5] Update user navbar and brewery card styling --- src/components/BreweryIndex/BreweryCard.tsx | 2 +- src/hooks/utilities/useNavbar.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/hooks/utilities/useNavbar.ts b/src/hooks/utilities/useNavbar.ts index 924cb39..064f519 100644 --- a/src/hooks/utilities/useNavbar.ts +++ b/src/hooks/utilities/useNavbar.ts @@ -36,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' }, ]; From 0d23b0295780788493d443a37754d826820b506e Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 14:15:32 -0500 Subject: [PATCH 3/5] Update profile schema --- .../User/schema/UpdateProfileSchema.tsx | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/services/User/schema/UpdateProfileSchema.tsx b/src/services/User/schema/UpdateProfileSchema.tsx index 9e8b33e..93c3ed0 100644 --- a/src/services/User/schema/UpdateProfileSchema.tsx +++ b/src/services/User/schema/UpdateProfileSchema.tsx @@ -2,27 +2,28 @@ import { z } from 'zod'; const UpdateProfileSchema = z.object({ bio: z.string().min(1, 'Bio cannot be empty'), - userAvatar: z.optional( - 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)] - .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.' }, - ), - ), + 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 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.' }, + ), }); export default UpdateProfileSchema; From cf89dc92df0185a05967cecf939e6663c0c622a4 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 22:42:07 -0500 Subject: [PATCH 4/5] Restructure account api routes, fix update profile --- src/components/Account/UpdateProfileLink.tsx | 29 +++++ src/components/UserPage/UserHeader.tsx | 2 +- .../middleware/checkIfBeerPostOwner.ts | 31 ----- .../user-follows/useGetUsersFollowedByUser.ts | 6 +- .../user-follows/useGetUsersFollowingUser.ts | 4 + src/hooks/utilities/useNavbar.ts | 2 +- .../{profile.ts => [id]/profile/index.ts} | 0 .../api/users/[id]/profile/update-avatar.ts | 118 ++++++++++++++++++ .../api/users/[id]/profile/update-bio.ts | 89 +++++++++++++ .../account/edit-profile.tsx} | 93 +++++++++----- src/pages/{ => users}/account/index.tsx | 2 + .../Account/sendUpdateProfileRequest.tsx | 32 ----- .../Account/sendUpdateUserAvatarRequest.ts | 27 ++++ .../sendUpdateUserProfileRequest.ts.ts | 28 +++++ ...ofileSchema.tsx => UpdateProfileSchema.ts} | 6 +- 15 files changed, 366 insertions(+), 103 deletions(-) create mode 100644 src/components/Account/UpdateProfileLink.tsx delete mode 100644 src/config/nextConnect/middleware/checkIfBeerPostOwner.ts rename src/pages/api/users/{profile.ts => [id]/profile/index.ts} (100%) create mode 100644 src/pages/api/users/[id]/profile/update-avatar.ts create mode 100644 src/pages/api/users/[id]/profile/update-bio.ts rename src/pages/{account/profile.tsx => users/account/edit-profile.tsx} (68%) rename src/pages/{ => users}/account/index.tsx (96%) delete mode 100644 src/requests/Account/sendUpdateProfileRequest.tsx create mode 100644 src/requests/Account/sendUpdateUserAvatarRequest.ts create mode 100644 src/requests/Account/sendUpdateUserProfileRequest.ts.ts rename src/services/User/schema/{UpdateProfileSchema.tsx => UpdateProfileSchema.ts} (82%) 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/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index b5280c4..156d66c 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -70,7 +70,7 @@ const UserHeader: FC = ({ user }) => { mutateFollowingCount={mutateFollowingCount} /> ) : ( - + 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 c8df097..dce2bfa 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts @@ -29,9 +29,9 @@ const useGetUsersFollowedByUser = ({ pageSize?: number; userId: string | undefined; }) => { - const fetcher = async (url: string | undefined) => { - if (!url) { - throw new Error('URL is undefined'); + const fetcher = async (url: string) => { + if (!userId) { + throw new Error('User ID is undefined'); } const response = await fetch(url); if (!response.ok) { diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts index a5ab521..fb3eb3d 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -33,6 +33,10 @@ interface UseGetUsersFollowingUser { 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 064f519..586d5e9 100644 --- a/src/hooks/utilities/useNavbar.ts +++ b/src/hooks/utilities/useNavbar.ts @@ -23,7 +23,7 @@ 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' }, ]; diff --git a/src/pages/api/users/profile.ts b/src/pages/api/users/[id]/profile/index.ts similarity index 100% rename from src/pages/api/users/profile.ts rename to src/pages/api/users/[id]/profile/index.ts 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/account/profile.tsx b/src/pages/users/account/edit-profile.tsx similarity index 68% rename from src/pages/account/profile.tsx rename to src/pages/users/account/edit-profile.tsx index 44057ff..bb04dad 100644 --- a/src/pages/account/profile.tsx +++ b/src/pages/users/account/edit-profile.tsx @@ -1,23 +1,30 @@ -import withPageAuthRequired from '@/util/withPageAuthRequired'; -import { GetServerSideProps, NextPage } from 'next'; -import { z } from 'zod'; +import { useContext, useEffect } from 'react'; + +import { GetServerSideProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; -import { zodResolver } from '@hookform/resolvers/zod'; 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 UserAvatar from '@/components/Account/UserAvatar'; -import { useContext, useEffect } from 'react'; 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 Head from 'next/head'; -import UpdateProfileSchema from '../../services/User/schema/UpdateProfileSchema'; -import sendUpdateProfileRequest from '../../requests/Account/sendUpdateProfileRequest'; -import UpdateProfileForm from '../../components/Account/UpdateProfileForm'; +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); @@ -28,31 +35,43 @@ const ProfilePage: NextPage = () => { setValue, formState: { errors, isSubmitting }, watch, - - reset, } = useForm>({ resolver: zodResolver(UpdateProfileSchema), - defaultValues: { - bio: user?.bio ?? '', - }, }); useEffect(() => { - if (!user || !user.bio) return; + if (!user || !user.bio) { + return; + } + setValue('bio', user.bio); }, [user, setValue]); + const router = useRouter(); + const onSubmit: SubmitHandler> = async (data) => { try { - await sendUpdateProfileRequest(data); const loadingToast = toast.loading('Updating profile...'); - await new Promise((resolve) => { - setTimeout(resolve, 1000); + 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); - reset(); - mutateUser!(); - toast.success('Profile updated!'); + await mutateUser!(); + await router.push(`/users/${user.id}`); + toast.success('Profile updated.'); } catch (error) { createErrorToast(error); } @@ -69,9 +88,11 @@ const ProfilePage: NextPage = () => { const watchedInput = watch('userAvatar'); if ( - !(watchedInput instanceof FileList) || - watchedInput.length !== 1 || - !watchedInput[0].type.startsWith('image/') + !( + watchedInput instanceof FileList && + watchedInput.length === 1 && + watchedInput[0].type.startsWith('image/') + ) ) { return ''; } @@ -87,13 +108,13 @@ const ProfilePage: NextPage = () => { The Biergarten App || Update Your Profile -
- {user && ( +
+ {user ? (
-
+
{ />
-

{user.username}

+

+ {user.username} +

-
+
{followingCount} Following {followerCount} Followers
-

- {watch('bio') || ( +

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

@@ -141,6 +166,10 @@ const ProfilePage: NextPage = () => {
+ ) : ( +
+ +
)}
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/sendUpdateProfileRequest.tsx b/src/requests/Account/sendUpdateProfileRequest.tsx deleted file mode 100644 index 1a6b86b..0000000 --- a/src/requests/Account/sendUpdateProfileRequest.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import UpdateProfileSchema from '@/services/User/schema/UpdateProfileSchema'; - -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(); - if (userAvatar[0]) { - 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; -}; - -export default sendUpdateProfileRequest; 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.tsx b/src/services/User/schema/UpdateProfileSchema.ts similarity index 82% rename from src/services/User/schema/UpdateProfileSchema.tsx rename to src/services/User/schema/UpdateProfileSchema.ts index 93c3ed0..b8face2 100644 --- a/src/services/User/schema/UpdateProfileSchema.tsx +++ b/src/services/User/schema/UpdateProfileSchema.ts @@ -1,14 +1,14 @@ import { z } from 'zod'; const UpdateProfileSchema = z.object({ - bio: z.string().min(1, 'Bio cannot be empty'), + 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 one file.', + .refine((fileList) => [...(fileList as FileList)].length <= 1, { + message: 'You must upload only one or zero files.', }) .refine( (fileList) => From da1ea4eef3c495478cd8ce671ec25233787fd299 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 22:45:40 -0500 Subject: [PATCH 5/5] style: update user header --- src/components/UserPage/UserHeader.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index 156d66c..7715b41 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -58,9 +58,11 @@ const UserHeader: FC = ({ user }) => {
-
-

{user.bio}

-
+ {user.bio && ( +
+

{user.bio}

+
+ )}
{currentUser?.id !== user.id ? ( @@ -70,7 +72,7 @@ const UserHeader: FC = ({ user }) => { mutateFollowingCount={mutateFollowingCount} /> ) : ( - + Edit Profile )}