From d57623e7052bfa0d975f9d498fe8e17cbf65b1e7 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sat, 2 Dec 2023 13:58:17 -0500 Subject: [PATCH] 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%)',