Feat: implement client side functionality for user follow feature

This commit is contained in:
Aaron William Po
2023-11-27 22:04:55 -05:00
parent e0e22ba7af
commit 7c87be09cf
11 changed files with 300 additions and 25 deletions

View File

@@ -0,0 +1,66 @@
import useFollowStatus from '@/hooks/data-fetching/user-follows/useFollowStatus';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
import sendUserFollowRequest from '@/requests/UserFollow/sendUserFollowRequest';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { FC, useState } from 'react';
import { FaUserCheck, FaUserPlus } from 'react-icons/fa';
import { z } from 'zod';
interface UserFollowButtonProps {
mutateFollowerCount: ReturnType<typeof useGetUsersFollowingUser>['mutate'];
mutateFollowingCount: ReturnType<typeof useGetUsersFollowedByUser>['mutate'];
user: z.infer<typeof GetUserSchema>;
}
const UserFollowButton: FC<UserFollowButtonProps> = ({
user,
mutateFollowerCount,
mutateFollowingCount,
}) => {
const { isFollowed, mutate: mutateFollowStatus } = useFollowStatus(user.id);
const [isLoading, setIsLoading] = useState(false);
const onClick = async () => {
try {
setIsLoading(true);
await sendUserFollowRequest(user.id);
await Promise.all([
mutateFollowStatus(),
mutateFollowerCount(),
mutateFollowingCount(),
]);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
}
};
return (
<button
type="button"
className={`btn-sm btn gap-2 rounded-2xl lg:btn-md ${
!isFollowed ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
onClick();
}}
disabled={isLoading}
>
{isFollowed ? (
<>
<FaUserCheck className="text-xl" />
Followed
</>
) : (
<>
<FaUserPlus className="text-xl" />
Follow
</>
)}
</button>
);
};
export default UserFollowButton;

View File

@@ -1,20 +1,30 @@
import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
import { FC } from 'react'; import { FC } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { format } from 'date-fns'; import { format } from 'date-fns';
import GetUserSchema from '@/services/User/schema/GetUserSchema'; import GetUserSchema from '@/services/User/schema/GetUserSchema';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
import UserAvatar from '../Account/UserAvatar'; import UserAvatar from '../Account/UserAvatar';
import UserFollowButton from './UserFollowButton';
interface UserHeaderProps { interface UserHeaderProps {
user: z.infer<typeof GetUserSchema>; user: z.infer<typeof GetUserSchema>;
followerCount: ReturnType<typeof useGetUsersFollowingUser>['followerCount'];
followingCount: ReturnType<typeof useGetUsersFollowedByUser>['followingCount'];
} }
const UserHeader: FC<UserHeaderProps> = ({ user, followerCount, followingCount }) => { const UserHeader: FC<UserHeaderProps> = ({ user }) => {
const timeDistance = useTimeDistance(new Date(user.createdAt)); const timeDistance = useTimeDistance(new Date(user.createdAt));
const { followingCount, mutate: mutateFollowingCount } = useGetUsersFollowedByUser({
userId: user.id,
pageSize: 10,
});
const { followerCount, mutate: mutateFollowerCount } = useGetUsersFollowingUser({
userId: user.id,
pageSize: 10,
});
return ( return (
<header className="card text-center items-center"> <header className="card text-center items-center">
<div className="card-body items-center w-full"> <div className="card-body items-center w-full">
@@ -42,6 +52,13 @@ const UserHeader: FC<UserHeaderProps> = ({ user, followerCount, followingCount }
</span> </span>
)} )}
</span> </span>
<div className="h-20 flex items-center justify-center">
<UserFollowButton
mutateFollowerCount={mutateFollowerCount}
user={user}
mutateFollowingCount={mutateFollowingCount}
/>
</div>
</div> </div>
</header> </header>
); );

View File

@@ -0,0 +1,38 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import useSWR from 'swr';
import { z } from 'zod';
const useFollowStatus = (userFollowedId: string) => {
const { data, error, isLoading, mutate } = useSWR(
`/api/users/${userFollowedId}/is-followed`,
async (url) => {
const response = await fetch(url);
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('Invalid API response.');
}
const { payload } = parsed.data;
const parsedPayload = z.object({ isFollowed: z.boolean() }).safeParse(payload);
if (!parsedPayload.success) {
throw new Error('Invalid API response.');
}
const { isFollowed } = parsedPayload.data;
return isFollowed;
},
);
return {
isFollowed: data,
error: error as unknown,
isLoading,
mutate,
};
};
export default useFollowStatus;

View File

@@ -34,7 +34,7 @@ const useGetUsersFollowedByUser = ({
return { following: parsedPayload.data, pageCount, followingCount: count }; return { following: parsedPayload.data, pageCount, followingCount: count };
}; };
const { data, error, isLoading, setSize, size } = useSWRInfinite( const { data, error, isLoading, setSize, size, mutate } = useSWRInfinite(
(index) => (index) =>
`/api/users/${userId}/following?page_num=${index + 1}&page_size=${pageSize}`, `/api/users/${userId}/following?page_num=${index + 1}&page_size=${pageSize}`,
fetcher, fetcher,
@@ -57,6 +57,7 @@ const useGetUsersFollowedByUser = ({
isLoading, isLoading,
isLoadingMore, isLoadingMore,
isAtEnd, isAtEnd,
mutate,
error: error as unknown, error: error as unknown,
}; };
}; };

View File

@@ -34,7 +34,7 @@ const useGetUsersFollowingUser = ({
return { followers: parsedPayload.data, pageCount, followerCount: count }; return { followers: parsedPayload.data, pageCount, followerCount: count };
}; };
const { data, error, isLoading, setSize, size } = useSWRInfinite( const { data, error, isLoading, setSize, size, mutate } = useSWRInfinite(
(index) => (index) =>
`/api/users/${userId}/followers?page_num=${index + 1}&page_size=${pageSize}`, `/api/users/${userId}/followers?page_num=${index + 1}&page_size=${pageSize}`,
fetcher, fetcher,
@@ -57,6 +57,7 @@ const useGetUsersFollowingUser = ({
isLoading, isLoading,
isLoadingMore, isLoadingMore,
isAtEnd, isAtEnd,
mutate,
error: error as unknown, error: error as unknown,
}; };
}; };

View File

@@ -35,7 +35,7 @@ const AccountPage: NextPage = () => {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="m-12 flex w-11/12 flex-col items-center justify-center space-y-3 lg:w-8/12"> <div className="m-12 flex w-11/12 flex-col items-center justify-center space-y-3 lg:w-8/12">
<div className="flex flex-col items-center space-y-3"> <div className="flex flex-col items-center space-y-3">
<div className="h-20 mb-10 w-20"> <div className="h-28 mb-1 w-28">
<UserAvatar user={user} /> <UserAvatar user={user} />
</div> </div>

View File

@@ -0,0 +1,81 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
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 findUserById from '@/services/User/findUserById';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest {
query: { id: string };
}
const router = createRouter<
GetUserFollowInfoRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
const followUser = async (
req: GetUserFollowInfoRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
const user = await findUserById(id);
if (!user) {
throw new ServerError('User not found', 404);
}
const currentUser = req.user!;
const userIsFollowedBySessionUser = await DBClient.instance.userFollow.findFirst({
where: {
followerId: currentUser.id,
followingId: id,
},
});
if (!userIsFollowedBySessionUser) {
await DBClient.instance.userFollow.create({
data: { followerId: currentUser.id, followingId: id },
});
res.status(200).json({
message: 'Now following user.',
success: true,
statusCode: 200,
});
return;
}
await DBClient.instance.userFollow.delete({
where: {
followerId_followingId: {
followerId: currentUser.id,
followingId: id,
},
},
});
res.status(200).json({
message: 'No longer following user.',
success: true,
statusCode: 200,
});
};
router.post(
validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
getCurrentUser,
followUser,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -0,0 +1,71 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
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 findUserById from '@/services/User/findUserById';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest {
query: { id: string };
}
const router = createRouter<
GetUserFollowInfoRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
const checkIfUserIsFollowedBySessionUser = async (
req: GetUserFollowInfoRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { id } = req.query;
const user = await findUserById(id);
if (!user) {
throw new ServerError('User not found', 404);
}
const currentUser = req.user!;
const userIsFollowedBySessionUser = await DBClient.instance.userFollow.findFirst({
where: {
followerId: currentUser.id,
followingId: id,
},
});
if (!userIsFollowedBySessionUser) {
res.status(200).json({
message: 'User is not followed by the current user.',
success: true,
statusCode: 200,
payload: { isFollowed: false },
});
return;
}
res.status(200).json({
message: 'User is followed by the current user.',
success: true,
statusCode: 200,
payload: { isFollowed: true },
});
};
router.get(
validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
getCurrentUser,
checkIfUserIsFollowedBySessionUser,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -7,8 +7,6 @@ import { FC } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import withPageAuthRequired from '@/util/withPageAuthRequired'; import withPageAuthRequired from '@/util/withPageAuthRequired';
import UserHeader from '@/components/UserPage/UserHeader'; import UserHeader from '@/components/UserPage/UserHeader';
import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser';
import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser';
interface UserInfoPageProps { interface UserInfoPageProps {
user: z.infer<typeof GetUserSchema>; user: z.infer<typeof GetUserSchema>;
@@ -19,16 +17,6 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ user }) => {
const isDesktop = useMediaQuery('(min-width: 1024px)'); const isDesktop = useMediaQuery('(min-width: 1024px)');
const title = `${user.username} | The Biergarten App`; const title = `${user.username} | The Biergarten App`;
const { followingCount } = useGetUsersFollowedByUser({
userId: user.id,
pageSize: 10,
});
const { followerCount } = useGetUsersFollowingUser({
userId: user.id,
pageSize: 10,
});
return ( return (
<> <>
<Head> <Head>
@@ -38,11 +26,7 @@ const UserInfoPage: FC<UserInfoPageProps> = ({ user }) => {
<> <>
<main className="mb-12 mt-10 flex w-full items-center justify-center"> <main className="mb-12 mt-10 flex w-full items-center justify-center">
<div className="h-full w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12"> <div className="h-full w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
<UserHeader <UserHeader user={user} />
user={user}
followerCount={followerCount}
followingCount={followingCount}
/>
{isDesktop ? ( {isDesktop ? (
<div className="h-64 flex space-x-3"> <div className="h-64 flex space-x-3">

View File

@@ -0,0 +1,16 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
const sendUserFollowRequest = async (userId: string) => {
const response = await fetch(`/api/users/${userId}/follow-user`, { method: 'POST' });
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('Invalid API response.');
}
return parsed;
};
export default sendUserFollowRequest;