Feat: add user posts functionality for account page

This commit is contained in:
Aaron William Po
2023-11-26 21:45:03 -05:00
parent c0d90a84d8
commit 00e980d71e
10 changed files with 552 additions and 3 deletions

View File

@@ -0,0 +1,82 @@
import UserContext from '@/contexts/UserContext';
import useBeerPostsByUser from '@/hooks/data-fetching/beer-posts/useBeerPostsByUser';
import { FC, useContext, MutableRefObject, useRef } from 'react';
import { FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import BeerCard from '../BeerIndex/BeerCard';
import LoadingCard from '../ui/LoadingCard';
import Spinner from '../ui/Spinner';
const BeerPostsByUser: FC = () => {
const { user } = useContext(UserContext);
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
const PAGE_SIZE = 2;
const { beerPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
useBeerPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id });
const { ref: lastBeerPostRef } = useInView({
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
return (
<div className="mt-4" ref={pageRef}>
<div className="grid gap-6 xl:grid-cols-2">
{!!beerPosts.length && !isLoading && (
<>
{beerPosts.map((beerPost, i) => {
return (
<div
key={beerPost.id}
ref={beerPosts.length === i + 1 ? lastBeerPostRef : undefined}
>
<BeerCard post={beerPost} />
</div>
);
})}
</>
)}
{(isLoading || isLoadingMore) && (
<>
{Array.from({ length: PAGE_SIZE }, (_, i) => (
<LoadingCard key={i} />
))}
</>
)}
</div>
{(isLoading || isLoadingMore) && (
<div className="flex h-32 w-full items-center justify-center">
<Spinner size="sm" />
</div>
)}
{!!beerPosts.length && isAtEnd && !isLoading && (
<div className="flex h-20 items-center justify-center text-center">
<div className="tooltip tooltip-bottom" data-tip="Scroll back to top of page.">
<button
type="button"
className="btn-ghost btn-sm btn"
aria-label="Scroll back to top of page."
onClick={() => {
pageRef.current?.scrollIntoView({
behavior: 'smooth',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)}
{!beerPosts.length && !isLoading && (
<div className="flex h-24 w-full items-center justify-center">
<p className="text-lg font-bold">No posts yet.</p>
</div>
)}
</div>
);
};
export default BeerPostsByUser;

View File

@@ -0,0 +1,84 @@
import UserContext from '@/contexts/UserContext';
import { FC, useContext, MutableRefObject, useRef } from 'react';
import { FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import useBreweryPostsByUser from '@/hooks/data-fetching/brewery-posts/useBreweryPostsByUser';
import LoadingCard from '../ui/LoadingCard';
import Spinner from '../ui/Spinner';
import BreweryCard from '../BreweryIndex/BreweryCard';
const BreweryPostsByUser: FC = () => {
const { user } = useContext(UserContext);
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
const PAGE_SIZE = 2;
const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
useBreweryPostsByUser({ pageSize: PAGE_SIZE, userId: user!.id });
const { ref: lastBreweryPostRef } = useInView({
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
return (
<div className="mt-4" ref={pageRef}>
<div className="grid gap-6 xl:grid-cols-2">
{!!breweryPosts.length && !isLoading && (
<>
{breweryPosts.map((breweryPost, i) => {
return (
<div
key={breweryPost.id}
ref={breweryPosts.length === i + 1 ? lastBreweryPostRef : undefined}
>
<BreweryCard brewery={breweryPost} />
</div>
);
})}
</>
)}
{isLoadingMore && (
<>
{Array.from({ length: PAGE_SIZE }, (_, i) => (
<LoadingCard key={i} />
))}
</>
)}
</div>
{(isLoading || isLoadingMore) && (
<div className="flex h-32 w-full items-center justify-center">
<Spinner size="sm" />
</div>
)}
{!!breweryPosts.length && isAtEnd && !isLoading && (
<div className="flex h-20 items-center justify-center text-center">
<div className="tooltip tooltip-bottom" data-tip="Scroll back to top of page.">
<button
type="button"
className="btn-ghost btn-sm btn"
aria-label="Scroll back to top of page."
onClick={() => {
pageRef.current?.scrollIntoView({
behavior: 'smooth',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)}
{!breweryPosts.length && !isLoading && (
<div className="flex h-24 w-full items-center justify-center">
<p className="text-lg font-bold">No posts yet.</p>
</div>
)}
</div>
);
};
export default BreweryPostsByUser;

View File

@@ -0,0 +1,31 @@
import { Tab } from '@headlessui/react';
import { FC } from 'react';
import BeerPostsByUser from './BeerPostsByUser';
import BreweryPostsByUser from './BreweryPostsByUser';
const UserPosts: FC = () => {
return (
<div className="mt-4">
<div>
<Tab.Group>
<Tab.List className="tabs tabs-boxed items-center justify-center rounded-2xl">
<Tab className="tab tab-xl w-1/2 uppercase ui-selected:tab-active">Beers</Tab>
<Tab className="tab tab-xl w-1/2 uppercase ui-selected:tab-active">
Breweries
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<BeerPostsByUser />
</Tab.Panel>
<Tab.Panel>
<BreweryPostsByUser />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};
export default UserPosts;

View File

@@ -0,0 +1,62 @@
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import useSWRInfinite from 'swr/infinite';
import { z } from 'zod';
interface UseBeerPostsByUserParams {
pageSize: number;
userId: string;
}
const useBeerPostsByUser = ({ pageSize, userId }: UseBeerPostsByUserParams) => {
const fetcher = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const count = response.headers.get('X-Total-Count');
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed');
}
const parsedPayload = z.array(BeerPostQueryResult).safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('API response validation failed');
}
const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize);
return {
beerPosts: parsedPayload.data,
pageCount,
};
};
const { data, error, isLoading, setSize, size } = useSWRInfinite(
(index) =>
`/api/users/${userId}/posts/beers?page_num=${index + 1}&page_size=${pageSize}`,
fetcher,
);
const beerPosts = data?.flatMap((d) => d.beerPosts) ?? [];
const pageCount = data?.[0].pageCount ?? 0;
const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined';
const isAtEnd = !(size < data?.[0].pageCount!);
return {
beerPosts,
pageCount,
size,
setSize,
isLoading,
isLoadingMore,
isAtEnd,
error: error as unknown,
};
};
export default useBeerPostsByUser;

View File

@@ -0,0 +1,60 @@
import BreweryPostQueryResult from '@/services/BreweryPost/schema/BreweryPostQueryResult';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import useSWRInfinite from 'swr/infinite';
import { z } from 'zod';
interface UseBreweryPostsByUserParams {
pageSize: number;
userId: string;
}
const useBreweryPostsByUser = ({ pageSize, userId }: UseBreweryPostsByUserParams) => {
const fetcher = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const count = response.headers.get('X-Total-Count');
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed');
}
const parsedPayload = z.array(BreweryPostQueryResult).safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('API response validation failed');
}
const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize);
return { breweryPosts: parsedPayload.data, pageCount };
};
const { data, error, isLoading, setSize, size } = useSWRInfinite(
(index) =>
`/api/users/${userId}/posts/breweries?page_num=${index + 1}&page_size=${pageSize}`,
fetcher,
{ parallel: true },
);
const breweryPosts = data?.flatMap((d) => d.breweryPosts) ?? [];
const pageCount = data?.[0].pageCount ?? 0;
const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined';
const isAtEnd = !(size < data?.[0].pageCount!);
return {
breweryPosts,
pageCount,
size,
setSize,
isLoading,
isLoadingMore,
isAtEnd,
error: error as unknown,
};
};
export default useBreweryPostsByUser;

View File

@@ -10,6 +10,7 @@ import Security from '@/components/Account/Security';
import DeleteAccount from '@/components/Account/DeleteAccount'; import DeleteAccount from '@/components/Account/DeleteAccount';
import accountPageReducer from '@/reducers/accountPageReducer'; import accountPageReducer from '@/reducers/accountPageReducer';
import UserAvatar from '@/components/Account/UserAvatar'; import UserAvatar from '@/components/Account/UserAvatar';
import UserPosts from '@/components/Account/UserPosts';
const AccountPage: NextPage = () => { const AccountPage: NextPage = () => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
@@ -32,9 +33,11 @@ const AccountPage: NextPage = () => {
/> />
</Head> </Head>
<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-7/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">
<UserAvatar user={user} /> <div className="h-32">
<UserAvatar user={user} />
</div>
<div className="flex flex-col items-center space-y-1"> <div className="flex flex-col items-center space-y-1">
<p className="text-3xl font-bold">Hello, {user!.username}!</p> <p className="text-3xl font-bold">Hello, {user!.username}!</p>
@@ -58,7 +61,9 @@ const AccountPage: NextPage = () => {
<Security pageState={pageState} dispatch={dispatch} /> <Security pageState={pageState} dispatch={dispatch} />
<DeleteAccount pageState={pageState} dispatch={dispatch} /> <DeleteAccount pageState={pageState} dispatch={dispatch} />
</Tab.Panel> </Tab.Panel>
<Tab.Panel>Your posts!</Tab.Panel> <Tab.Panel className="h-full space-y-5">
<UserPosts />
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@@ -0,0 +1,58 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import getBeerPostsByPostedById from '@/services/BeerPost/getBeerPostsByPostedById';
import PaginatedQueryResponseSchema from '@/services/schema/PaginatedQueryResponseSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
interface GetBeerPostsRequest extends NextApiRequest {
query: {
page_num: string;
page_size: string;
id: string;
};
}
const getBeerPostsByUserId = async (
req: GetBeerPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { id } = req.query;
const beerPosts = await getBeerPostsByPostedById({ pageNum, pageSize, postedById: id });
const beerPostCount = await DBClient.instance.beerPost.count({
where: { postedBy: { id } },
});
res.setHeader('X-Total-Count', beerPostCount);
res.status(200).json({
message: `Beer posts by user ${id} fetched successfully`,
statusCode: 200,
payload: beerPosts,
success: true,
});
};
const router = createRouter<
GetBeerPostsRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(
validateRequest({
querySchema: PaginatedQueryResponseSchema.extend({ id: z.string().cuid() }),
}),
getBeerPostsByUserId,
);
const handler = router.handler();
export default handler;

View File

@@ -0,0 +1,62 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import getAllBreweryPostsByPostedById from '@/services/BreweryPost/getAllBreweryPostsByPostedById';
import PaginatedQueryResponseSchema from '@/services/schema/PaginatedQueryResponseSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
interface GetBreweryPostsRequest extends NextApiRequest {
query: {
page_num: string;
page_size: string;
id: string;
};
}
const getBreweryPostsByUserId = async (
req: GetBreweryPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.page_num, 10);
const pageSize = parseInt(req.query.page_size, 10);
const { id } = req.query;
const breweryPosts = await getAllBreweryPostsByPostedById({
pageNum,
pageSize,
postedById: id,
});
const breweryPostCount = await DBClient.instance.breweryPost.count({
where: { postedBy: { id } },
});
res.setHeader('X-Total-Count', breweryPostCount);
res.status(200).json({
message: `Brewery posts by user ${id} fetched successfully`,
statusCode: 200,
payload: breweryPosts,
success: true,
});
};
const router = createRouter<
GetBreweryPostsRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(
validateRequest({
querySchema: PaginatedQueryResponseSchema.extend({ id: z.string().cuid() }),
}),
getBreweryPostsByUserId,
);
const handler = router.handler();
export default handler;

View File

@@ -0,0 +1,47 @@
import DBClient from '@/prisma/DBClient';
import { z } from 'zod';
import BeerPostQueryResult from './schema/BeerPostQueryResult';
interface GetBeerPostsByBeerStyleIdArgs {
postedById: string;
pageSize: number;
pageNum: number;
}
const getBeerPostsByPostedById = async ({
pageNum,
pageSize,
postedById,
}: GetBeerPostsByBeerStyleIdArgs): Promise<z.infer<typeof BeerPostQueryResult>[]> => {
const beers = await DBClient.instance.beerPost.findMany({
where: { postedBy: { id: postedById } },
take: pageSize,
skip: pageNum * pageSize,
select: {
id: true,
name: true,
ibu: true,
abv: true,
createdAt: true,
updatedAt: true,
description: true,
postedBy: { select: { username: true, id: true } },
brewery: { select: { name: true, id: true } },
style: { select: { name: true, id: true, description: true } },
beerImages: {
select: {
alt: true,
path: true,
caption: true,
id: true,
createdAt: true,
updatedAt: true,
},
},
},
});
return beers;
};
export default getBeerPostsByPostedById;

View File

@@ -0,0 +1,58 @@
import DBClient from '@/prisma/DBClient';
import BreweryPostQueryResult from '@/services/BreweryPost/schema/BreweryPostQueryResult';
import { z } from 'zod';
const prisma = DBClient.instance;
const getAllBreweryPostsByPostedById = async ({
pageNum,
pageSize,
postedById,
}: {
pageNum: number;
pageSize: number;
postedById: string;
}): Promise<z.infer<typeof BreweryPostQueryResult>[]> => {
const breweryPosts = await prisma.breweryPost.findMany({
where: { postedBy: { id: postedById } },
take: pageSize,
skip: (pageNum - 1) * pageSize,
select: {
id: true,
location: {
select: {
city: true,
address: true,
coordinates: true,
country: true,
stateOrProvince: true,
},
},
description: true,
name: true,
postedBy: { select: { username: true, id: true } },
breweryImages: {
select: {
path: true,
caption: true,
id: true,
alt: true,
createdAt: true,
updatedAt: true,
},
},
createdAt: true,
dateEstablished: true,
},
orderBy: { createdAt: 'desc' },
});
/**
* Prisma does not support tuples, so we have to typecast the coordinates field to
* [number, number] in order to satisfy the zod schema.
*/
return breweryPosts as Awaited<ReturnType<typeof getAllBreweryPostsByPostedById>>;
};
export default getAllBreweryPostsByPostedById;