mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 18:52:06 +00:00
Feat: implement client side functionality for user follow feature
This commit is contained in:
66
src/components/UserPage/UserFollowButton.tsx
Normal file
66
src/components/UserPage/UserFollowButton.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
38
src/hooks/data-fetching/user-follows/useFollowStatus.ts
Normal file
38
src/hooks/data-fetching/user-follows/useFollowStatus.ts
Normal 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;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
81
src/pages/api/users/[id]/follow-user.ts
Normal file
81
src/pages/api/users/[id]/follow-user.ts
Normal 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;
|
||||||
71
src/pages/api/users/[id]/is-followed.ts
Normal file
71
src/pages/api/users/[id]/is-followed.ts
Normal 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;
|
||||||
@@ -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">
|
||||||
|
|||||||
16
src/requests/UserFollow/sendUserFollowRequest.ts
Normal file
16
src/requests/UserFollow/sendUserFollowRequest.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user