Continue work on brewery page, implement like system

This commit is contained in:
Aaron William Po
2023-04-23 17:25:39 -04:00
parent 9504da33d6
commit 58d30b605f
27 changed files with 699 additions and 125 deletions

View File

@@ -6,7 +6,7 @@ import UserContext from '@/contexts/userContext';
import { FaRegEdit } from 'react-icons/fa'; import { FaRegEdit } from 'react-icons/fa';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod'; import { z } from 'zod';
import useGetLikeCount from '@/hooks/useGetLikeCount'; import useGetBeerPostLikeCount from '@/hooks/useBeerPostLikeCount';
import useTimeDistance from '@/hooks/useTimeDistance'; import useTimeDistance from '@/hooks/useTimeDistance';
import BeerPostLikeButton from './BeerPostLikeButton'; import BeerPostLikeButton from './BeerPostLikeButton';
@@ -20,7 +20,7 @@ const BeerInfoHeader: FC<{
const idMatches = user && beerPost.postedBy.id === user.id; const idMatches = user && beerPost.postedBy.id === user.id;
const isPostOwner = !!(user && idMatches); const isPostOwner = !!(user && idMatches);
const { likeCount, mutate } = useGetLikeCount(beerPost.id); const { likeCount, mutate } = useGetBeerPostLikeCount(beerPost.id);
return ( return (
<main className="card flex flex-col justify-center bg-base-300"> <main className="card flex flex-col justify-center bg-base-300">

View File

@@ -1,13 +1,13 @@
import useCheckIfUserLikesBeerPost from '@/hooks/useCheckIfUserLikesBeerPost'; import useCheckIfUserLikesBeerPost from '@/hooks/useCheckIfUserLikesBeerPost';
import sendLikeRequest from '@/requests/sendLikeRequest'; import sendBeerPostLikeRequest from '@/requests/sendBeerPostLikeRequest';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa';
import useGetLikeCount from '@/hooks/useGetLikeCount'; import useGetBeerPostLikeCount from '@/hooks/useBeerPostLikeCount';
import LikeButton from '../ui/LikeButton';
const BeerPostLikeButton: FC<{ const BeerPostLikeButton: FC<{
beerPostId: string; beerPostId: string;
mutateCount: ReturnType<typeof useGetLikeCount>['mutate']; mutateCount: ReturnType<typeof useGetBeerPostLikeCount>['mutate'];
}> = ({ beerPostId, mutateCount }) => { }> = ({ beerPostId, mutateCount }) => {
const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerPost(beerPostId); const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerPost(beerPostId);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -19,7 +19,7 @@ const BeerPostLikeButton: FC<{
const handleLike = async () => { const handleLike = async () => {
try { try {
setLoading(true); setLoading(true);
await sendLikeRequest(beerPostId); await sendBeerPostLikeRequest(beerPostId);
await Promise.all([mutateCount(), mutateLikeStatus()]); await Promise.all([mutateCount(), mutateLikeStatus()]);
setLoading(false); setLoading(false);
@@ -28,30 +28,7 @@ const BeerPostLikeButton: FC<{
} }
}; };
return ( return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={loading} />;
<button
type="button"
className={`btn-sm btn gap-2 rounded-2xl lg:btn-md ${
!isLiked ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
handleLike();
}}
disabled={loading}
>
{isLiked ? (
<>
<FaThumbsUp className="lg:text-2xl" />
Liked
</>
) : (
<>
<FaRegThumbsUp className="lg:text-2xl" />
Like
</>
)}
</button>
);
}; };
export default BeerPostLikeButton; export default BeerPostLikeButton;

View File

@@ -4,12 +4,12 @@ import Image from 'next/image';
import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import beerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import { z } from 'zod'; import { z } from 'zod';
import UserContext from '@/contexts/userContext'; import UserContext from '@/contexts/userContext';
import useGetLikeCount from '@/hooks/useGetLikeCount'; import useGetBeerPostLikeCount from '@/hooks/useBeerPostLikeCount';
import BeerPostLikeButton from '../BeerById/BeerPostLikeButton'; import BeerPostLikeButton from '../BeerById/BeerPostLikeButton';
const BeerCard: FC<{ post: z.infer<typeof beerPostQueryResult> }> = ({ post }) => { const BeerCard: FC<{ post: z.infer<typeof beerPostQueryResult> }> = ({ post }) => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
const { mutate, likeCount } = useGetLikeCount(post.id); const { mutate, likeCount } = useGetBeerPostLikeCount(post.id);
return ( return (
<div className="card card-compact bg-base-300" key={post.id}> <div className="card card-compact bg-base-300" key={post.id}>
@@ -27,14 +27,14 @@ const BeerCard: FC<{ post: z.infer<typeof beerPostQueryResult> }> = ({ post }) =
<div className="card-body justify-between"> <div className="card-body justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Link href={`/beers/${post.id}`}> <Link href={`/beers/${post.id}`}>
<h2 className="link-hover link overflow-hidden whitespace-normal text-2xl font-bold lg:truncate lg:text-3xl"> <h3 className="link-hover link overflow-hidden whitespace-normal text-2xl font-bold lg:truncate lg:text-3xl">
{post.name} {post.name}
</h2> </h3>
</Link> </Link>
<Link href={`/breweries/${post.brewery.id}`}> <Link href={`/breweries/${post.brewery.id}`}>
<h3 className="text-md link-hover link whitespace-normal lg:truncate lg:text-xl"> <h4 className="text-md link-hover link whitespace-normal lg:truncate lg:text-xl">
{post.brewery.name} {post.brewery.name}
</h3> </h4>
</Link> </Link>
</div> </div>
<div> <div>

View File

@@ -0,0 +1,43 @@
import UserContext from '@/contexts/userContext';
import useGetBreweryPostLikeCount from '@/hooks/useGetBreweryPostLikeCount';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { FC, useContext } from 'react';
import { Link } from 'react-daisyui';
import { z } from 'zod';
import Image from 'next/image';
import BreweryPostLikeButton from './BreweryPostLikeButton';
const BreweryCard: FC<{ brewery: z.infer<typeof BreweryPostQueryResult> }> = ({
brewery,
}) => {
const { user } = useContext(UserContext);
const { likeCount, mutate } = useGetBreweryPostLikeCount(brewery.id);
return (
<div className="card" key={brewery.id}>
<figure className="card-image h-96">
{brewery.breweryImages.length > 0 && (
<Image
src={brewery.breweryImages[0].path}
alt={brewery.name}
width="1029"
height="110"
/>
)}
</figure>
<div className="card-body space-y-3">
<div>
<h2 className="text-3xl font-bold">
<Link href={`/breweries/${brewery.id}`}>{brewery.name}</Link>
</h2>
<h3 className="text-xl font-semibold">{brewery.location}</h3>
</div>
liked by {likeCount} users
{user && (
<BreweryPostLikeButton breweryPostId={brewery.id} mutateCount={mutate} />
)}
</div>
</div>
);
};
export default BreweryCard;

View File

@@ -0,0 +1,30 @@
import useCheckIfUserLikesBreweryPost from '@/hooks/useCheckIfUserLikesBreweryPost';
import useGetBreweryPostLikeCount from '@/hooks/useGetBreweryPostLikeCount';
import sendBreweryPostLikeRequest from '@/requests/sendBreweryPostLikeRequest';
import { FC, useState } from 'react';
import LikeButton from '../ui/LikeButton';
const BreweryPostLikeButton: FC<{
breweryPostId: string;
mutateCount: ReturnType<typeof useGetBreweryPostLikeCount>['mutate'];
}> = ({ breweryPostId, mutateCount }) => {
const { isLiked, mutate: mutateLikeStatus } =
useCheckIfUserLikesBreweryPost(breweryPostId);
const [isLoading, setIsLoading] = useState(false);
const handleLike = async () => {
try {
setIsLoading(true);
await sendBreweryPostLikeRequest(breweryPostId);
await Promise.all([mutateCount(), mutateLikeStatus()]);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
}
};
return <LikeButton isLiked={!!isLiked} handleLike={handleLike} loading={isLoading} />;
};
export default BreweryPostLikeButton;

View File

@@ -0,0 +1,37 @@
import { FC } from 'react';
import { FaThumbsUp, FaRegThumbsUp } from 'react-icons/fa';
interface LikeButtonProps {
isLiked: boolean;
handleLike: () => Promise<void>;
loading: boolean;
}
const LikeButton: FC<LikeButtonProps> = ({ isLiked, handleLike, loading }) => {
return (
<button
type="button"
className={`btn-sm btn gap-2 rounded-2xl lg:btn-md ${
!isLiked ? 'btn-ghost outline' : 'btn-primary'
}`}
onClick={() => {
handleLike();
}}
disabled={loading}
>
{isLiked ? (
<>
<FaThumbsUp className="lg:text-2xl" />
Liked
</>
) : (
<>
<FaRegThumbsUp className="lg:text-2xl" />
Like
</>
)}
</button>
);
};
export default LikeButton;

View File

@@ -1,6 +1,6 @@
import { FC } from 'react'; import { FC } from 'react';
const BeerPostLoadingCard: FC = () => { const LoadingCard: FC = () => {
return ( return (
<div className="card bg-base-300"> <div className="card bg-base-300">
<figure className="h-96 border-8 border-base-300 bg-base-300"> <figure className="h-96 border-8 border-base-300 bg-base-300">
@@ -23,4 +23,4 @@ const BeerPostLoadingCard: FC = () => {
); );
}; };
export default BeerPostLoadingCard; export default LoadingCard;

View File

@@ -27,8 +27,8 @@ export type ExtendedGetServerSideProps<
) => Promise<GetServerSidePropsResult<P>>; ) => Promise<GetServerSidePropsResult<P>>;
/** /**
* A Higher Order Function that adds authentication requirement to a Next.js server-side * A Higher Order Function that adds an authentication requirement to a Next.js
* page component. * server-side page component.
* *
* @param fn An async function that receives the GetServerSidePropsContext and * @param fn An async function that receives the GetServerSidePropsContext and
* authenticated session as arguments and returns a GetServerSidePropsResult with props * authenticated session as arguments and returns a GetServerSidePropsResult with props

View File

@@ -10,7 +10,7 @@ import useSWR from 'swr';
* state of the request. * state of the request.
*/ */
const useGetLikeCount = (beerPostId: string) => { const useGetBeerPostLikeCount = (beerPostId: string) => {
const { error, mutate, data, isLoading } = useSWR( const { error, mutate, data, isLoading } = useSWR(
`/api/beers/${beerPostId}/like`, `/api/beers/${beerPostId}/like`,
async (url) => { async (url) => {
@@ -45,4 +45,4 @@ const useGetLikeCount = (beerPostId: string) => {
}; };
}; };
export default useGetLikeCount; export default useGetBeerPostLikeCount;

View File

@@ -0,0 +1,62 @@
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import useSWRInfinite from 'swr/infinite';
import { z } from 'zod';
/**
* A custom hook using SWR to fetch brewery posts from the API.
*
* @param options The options to use when fetching brewery posts.
* @param options.pageSize The number of brewery posts to fetch per page.
* @returns An object containing the brewery posts, page count, and loading state.
*/
const useBreweryPosts = ({ pageSize }: { pageSize: number }) => {
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/breweries?pageNum=${index + 1}&pageSize=${pageSize}`,
fetcher,
);
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 useBreweryPosts;

View File

@@ -0,0 +1,45 @@
import UserContext from '@/contexts/userContext';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { useContext } from 'react';
import useSWR from 'swr';
import { z } from 'zod';
const useCheckIfUserLikesBreweryPost = (breweryPostId: string) => {
const { user } = useContext(UserContext);
const { data, error, isLoading, mutate } = useSWR(
`/api/breweries/${breweryPostId}/like/is-liked`,
async () => {
if (!user) {
throw new Error('User is not logged in.');
}
const response = await fetch(`/api/breweries/${breweryPostId}/like/is-liked`);
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({ isLiked: z.boolean() }).safeParse(payload);
if (!parsedPayload.success) {
throw new Error('Invalid API response.');
}
const { isLiked } = parsedPayload.data;
return isLiked;
},
);
return {
isLiked: data,
error: error as unknown,
isLoading,
mutate,
};
};
export default useCheckIfUserLikesBreweryPost;

View File

@@ -0,0 +1,40 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import useSWR from 'swr';
import { z } from 'zod';
const useGetBreweryPostLikeCount = (breweryPostId: string) => {
const { error, mutate, data, isLoading } = useSWR(
`/api/breweries/${breweryPostId}/like`,
async (url) => {
const response = await fetch(url);
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('Failed to parse API response');
}
const parsedPayload = z
.object({
likeCount: z.number(),
})
.safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('Failed to parse API response payload');
}
return parsedPayload.data.likeCount;
},
);
return {
error: error as unknown,
isLoading,
mutate,
likeCount: data as number | undefined,
};
};
export default useGetBreweryPostLikeCount;

View File

@@ -50,7 +50,7 @@ const getLikeCount = async (
) => { ) => {
const id = req.query.id as string; const id = req.query.id as string;
const likes = await DBClient.instance.beerPostLike.count({ const likeCount = await DBClient.instance.beerPostLike.count({
where: { beerPostId: id }, where: { beerPostId: id },
}); });
@@ -58,7 +58,7 @@ const getLikeCount = async (
success: true, success: true,
message: 'Successfully retrieved like count.', message: 'Successfully retrieved like count.',
statusCode: 200, statusCode: 200,
payload: { likeCount: likes }, payload: { likeCount },
}); });
}; };

View File

@@ -0,0 +1,97 @@
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 APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
const sendLikeRequest = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const id = req.query.id! as string;
const user = req.user!;
const breweryPost = await DBClient.instance.breweryPost.findUnique({
where: { id },
});
if (!breweryPost) {
throw new ServerError('Could not find a brewery post with that id', 404);
}
const alreadyLiked = await DBClient.instance.breweryPostLike.findFirst({
where: { breweryPostId: breweryPost.id, likedById: user.id },
});
const jsonResponse = {
success: true as const,
message: '',
statusCode: 200 as const,
};
if (alreadyLiked) {
await DBClient.instance.breweryPostLike.delete({
where: { id: alreadyLiked.id },
});
jsonResponse.message = 'Successfully unliked brewery post';
} else {
await DBClient.instance.breweryPostLike.create({
data: { breweryPostId: breweryPost.id, likedById: user.id },
});
jsonResponse.message = 'Successfully liked brewery post';
}
res.status(200).json(jsonResponse);
};
const getLikeCount = async (
req: NextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const id = req.query.id! as string;
const breweryPost = await DBClient.instance.breweryPost.findUnique({
where: { id },
});
if (!breweryPost) {
throw new ServerError('Could not find a brewery post with that id', 404);
}
const likeCount = await DBClient.instance.breweryPostLike.count({
where: { breweryPostId: breweryPost.id },
});
res.status(200).json({
success: true,
message: 'Successfully retrieved like count',
statusCode: 200,
payload: { likeCount },
});
};
const router = createRouter<
UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(
getCurrentUser,
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }),
sendLikeRequest,
);
router.get(
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }),
getLikeCount,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -0,0 +1,49 @@
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 DBClient from '@/prisma/DBClient';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
const checkIfLiked = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const id = req.query.id as string;
const alreadyLiked = await DBClient.instance.breweryPostLike.findFirst({
where: {
breweryPostId: id,
likedById: user.id,
},
});
res.status(200).json({
success: true,
message: alreadyLiked ? 'Brewery post is liked.' : 'Brewery post is not liked.',
statusCode: 200,
payload: { isLiked: !!alreadyLiked },
});
};
const router = createRouter<
UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(
getCurrentUser,
validateRequest({
querySchema: z.object({
id: z.string().uuid(),
}),
}),
checkIfLiked,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -0,0 +1,54 @@
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
interface GetBreweryPostsRequest extends NextApiRequest {
query: {
pageNum: string;
pageSize: string;
};
}
const getBreweryPosts = async (
req: GetBreweryPostsRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const pageNum = parseInt(req.query.pageNum, 10);
const pageSize = parseInt(req.query.pageSize, 10);
const breweryPosts = await getAllBreweryPosts(pageNum, pageSize);
const breweryPostCount = await DBClient.instance.breweryPost.count();
res.setHeader('X-Total-Count', breweryPostCount);
res.status(200).json({
message: 'Brewery posts retrieved successfully',
statusCode: 200,
payload: breweryPosts,
success: true,
});
};
const router = createRouter<
GetBreweryPostsRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(
validateRequest({
querySchema: z.object({
pageNum: z.string().regex(/^\d+$/),
pageSize: z.string().regex(/^\d+$/),
}),
}),
getBreweryPosts,
);
const handler = router.handler();
export default handler;

View File

@@ -15,7 +15,7 @@ interface CreateBeerPageProps {
types: BeerType[]; types: BeerType[];
} }
const Create: NextPage<CreateBeerPageProps> = ({ breweries, types }) => { const CreateBeerPost: NextPage<CreateBeerPageProps> = ({ breweries, types }) => {
return ( return (
<FormPageLayout <FormPageLayout
headingText="Create a new beer" headingText="Create a new beer"
@@ -40,4 +40,4 @@ export const getServerSideProps = withPageAuthRequired<CreateBeerPageProps>(asyn
}; };
}); });
export default Create; export default CreateBeerPost;

View File

@@ -10,8 +10,8 @@ import { useInView } from 'react-intersection-observer';
import Spinner from '@/components/ui/Spinner'; import Spinner from '@/components/ui/Spinner';
import useBeerPosts from '@/hooks/useBeerPosts'; import useBeerPosts from '@/hooks/useBeerPosts';
import BeerPostLoadingCard from '@/components/BeerIndex/BeerPostLoadingCard';
import { FaArrowUp, FaPlus } from 'react-icons/fa'; import { FaArrowUp, FaPlus } from 'react-icons/fa';
import LoadingCard from '@/components/ui/LoadingCard';
const BeerPage: NextPage = () => { const BeerPage: NextPage = () => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
@@ -40,7 +40,10 @@ const BeerPage: NextPage = () => {
<div className="flex items-center justify-center bg-base-100" ref={pageRef}> <div className="flex items-center justify-center bg-base-100" ref={pageRef}>
<div className="my-10 flex w-10/12 flex-col space-y-4 lg:w-8/12 2xl:w-7/12"> <div className="my-10 flex w-10/12 flex-col space-y-4 lg:w-8/12 2xl:w-7/12">
<header className="my-10 flex justify-between lg:flex-row"> <header className="my-10 flex justify-between lg:flex-row">
<h1 className="text-4xl font-bold lg:text-6xl">The Biergarten Index</h1> <div>
<h1 className="text-4xl font-bold lg:text-6xl">The Biergarten Index</h1>
<h2 className="text-2xl font-bold lg:text-4xl">Beers</h2>
</div>
{!!user && ( {!!user && (
<div <div
className="tooltip tooltip-left h-full" className="tooltip tooltip-left h-full"
@@ -70,7 +73,7 @@ const BeerPage: NextPage = () => {
{(isLoading || isLoadingMore) && ( {(isLoading || isLoadingMore) && (
<> <>
{Array.from({ length: PAGE_SIZE }, (_, i) => ( {Array.from({ length: PAGE_SIZE }, (_, i) => (
<BeerPostLoadingCard key={i} /> <LoadingCard key={i} />
))} ))}
</> </>
)} )}

View File

@@ -1,70 +1,115 @@
import { GetServerSideProps, NextPage } from 'next'; import BreweryCard from '@/components/BreweryIndex/BreweryCard';
import LoadingCard from '@/components/ui/LoadingCard';
import Link from 'next/link'; import Spinner from '@/components/ui/Spinner';
import getAllBreweryPosts from '@/services/BreweryPost/getAllBreweryPosts'; import UserContext from '@/contexts/userContext';
import useBreweryPosts from '@/hooks/useBreweryPosts';
import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult'; import BreweryPostQueryResult from '@/services/BreweryPost/types/BreweryPostQueryResult';
import { NextPage } from 'next';
import { FC } from 'react'; import { useContext, MutableRefObject, useRef } from 'react';
import Image from 'next/image'; import { Link } from 'react-daisyui';
import { FaPlus, FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod'; import { z } from 'zod';
interface BreweryPageProps { interface BreweryPageProps {
breweryPosts: z.infer<typeof BreweryPostQueryResult>[]; breweryPosts: z.infer<typeof BreweryPostQueryResult>[];
} }
const BreweryCard: FC<{ brewery: z.infer<typeof BreweryPostQueryResult> }> = ({ const BreweryPage: NextPage<BreweryPageProps> = () => {
brewery, const PAGE_SIZE = 6;
}) => {
const { breweryPosts, setSize, size, isLoading, isLoadingMore, isAtEnd } =
useBreweryPosts({
pageSize: PAGE_SIZE,
});
const { ref: lastBreweryPostRef } = useInView({
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
const { user } = useContext(UserContext);
const pageRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
return ( return (
<div className="card" key={brewery.id}> <div className="flex items-center justify-center bg-base-100" ref={pageRef}>
<figure className="card-image h-96"> <div className="my-10 flex w-10/12 flex-col space-y-4 lg:w-8/12 2xl:w-7/12">
{brewery.breweryImages.length > 0 && ( <header className="my-10 flex justify-between lg:flex-row">
<Image <div>
src={brewery.breweryImages[0].path} <h1 className="text-4xl font-bold lg:text-6xl">The Biergarten Index</h1>
alt={brewery.name} <h2 className="text-2xl font-bold lg:text-4xl">Breweries</h2>
width="1029" </div>
height="110" {!!user && (
/> <div
)} className="tooltip tooltip-left h-full"
</figure> data-tip="Create a new brewery post"
<div className="card-body space-y-3"> >
<div> <Link href="/breweries/create" className="btn-ghost btn-sm btn">
<h2 className="text-3xl font-bold"> <FaPlus />
<Link href={`/breweries/${brewery.id}`}>{brewery.name}</Link> </Link>
</h2> </div>
<h3 className="text-xl font-semibold">{brewery.location}</h3> )}
</header>
<div className="grid gap-6 xl:grid-cols-2">
{!!breweryPosts.length && !isLoading && (
<>
{breweryPosts.map((breweryPost) => {
return (
<div
key={breweryPost.id}
ref={
breweryPosts[breweryPosts.length - 1] === breweryPost
? lastBreweryPostRef
: undefined
}
>
<BreweryCard brewery={breweryPost} />
</div>
);
})}
</>
)}
{(isLoading || isLoadingMore) && (
<>
{Array.from({ length: PAGE_SIZE }, (_, i) => (
<LoadingCard key={i} />
))}
</>
)}
</div> </div>
{(isLoading || isLoadingMore) && (
<div className="flex h-32 w-full items-center justify-center">
<Spinner size="sm" />
</div>
)}
{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',
block: 'start',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
}; };
const BreweryPage: NextPage<BreweryPageProps> = ({ breweryPosts }) => {
return (
<>
<div className="flex items-center justify-center bg-base-100">
<div className="my-10 flex w-10/12 flex-col space-y-4">
<header className="my-10">
<div className="space-y-2">
<h1 className="text-6xl font-bold">Breweries</h1>
</div>
</header>
<div className="grid gap-5 md:grid-cols-1 xl:grid-cols-2">
{breweryPosts.map((brewery) => {
return <BreweryCard brewery={brewery} key={brewery.id} />;
})}
</div>
</div>
</div>
</>
);
};
export const getServerSideProps: GetServerSideProps<BreweryPageProps> = async () => {
const breweryPosts = await getAllBreweryPosts();
return {
props: { breweryPosts: JSON.parse(JSON.stringify(breweryPosts)) },
};
};
export default BreweryPage; export default BreweryPage;

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "BreweryPostLike" (
"id" STRING NOT NULL,
"breweryPostId" STRING NOT NULL,
"likedById" STRING NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3),
CONSTRAINT "BreweryPostLike_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "BreweryPostLike" ADD CONSTRAINT "BreweryPostLike_breweryPostId_fkey" FOREIGN KEY ("breweryPostId") REFERENCES "BreweryPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BreweryPostLike" ADD CONSTRAINT "BreweryPostLike_likedById_fkey" FOREIGN KEY ("likedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -11,15 +11,15 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
username String @unique username String @unique
firstName String firstName String
lastName String lastName String
hash String hash String
email String @unique email String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
isAccountVerified Boolean @default(false) isAccountVerified Boolean @default(false)
dateOfBirth DateTime dateOfBirth DateTime
beerPosts BeerPost[] beerPosts BeerPost[]
beerTypes BeerType[] beerTypes BeerType[]
@@ -29,6 +29,7 @@ model User {
BeerPostLikes BeerPostLike[] BeerPostLikes BeerPostLike[]
BeerImage BeerImage[] BeerImage BeerImage[]
BreweryImage BreweryImage[] BreweryImage BreweryImage[]
BreweryPostLike BreweryPostLike[]
} }
model BeerPost { model BeerPost {
@@ -60,6 +61,16 @@ model BeerPostLike {
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
} }
model BreweryPostLike {
id String @id @default(uuid())
breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade)
breweryPostId String
likedBy User @relation(fields: [likedById], references: [id], onDelete: Cascade)
likedById String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
}
model BeerComment { model BeerComment {
id String @id @default(uuid()) id String @id @default(uuid())
rating Int rating Int
@@ -83,17 +94,18 @@ model BeerType {
} }
model BreweryPost { model BreweryPost {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
location String location String
beers BeerPost[] beers BeerPost[]
description String description String
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade) postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String postedById String
breweryComments BreweryComment[] breweryComments BreweryComment[]
breweryImages BreweryImage[] breweryImages BreweryImage[]
BreweryPostLike BreweryPostLike[]
} }
model BreweryComment { model BreweryComment {

View File

@@ -0,0 +1,33 @@
import type { BreweryPost, BreweryPostLike, User } from '@prisma/client';
import DBClient from '../../DBClient';
const createNewBreweryPostLikes = async ({
joinData: { breweryPosts, users },
numberOfLikes,
}: {
joinData: {
breweryPosts: BreweryPost[];
users: User[];
};
numberOfLikes: number;
}) => {
const breweryPostLikePromises: Promise<BreweryPostLike>[] = [];
// eslint-disable-next-line no-plusplus
for (let i = 0; i < numberOfLikes; i++) {
const breweryPost = breweryPosts[Math.floor(Math.random() * breweryPosts.length)];
const user = users[Math.floor(Math.random() * users.length)];
breweryPostLikePromises.push(
DBClient.instance.breweryPostLike.create({
data: {
breweryPost: { connect: { id: breweryPost.id } },
likedBy: { connect: { id: user.id } },
},
}),
);
}
return Promise.all(breweryPostLikePromises);
};
export default createNewBreweryPostLikes;

View File

@@ -13,6 +13,7 @@ import createNewBreweryImages from './create/createNewBreweryImages';
import createNewBreweryPostComments from './create/createNewBreweryPostComments'; import createNewBreweryPostComments from './create/createNewBreweryPostComments';
import createNewBreweryPosts from './create/createNewBreweryPosts'; import createNewBreweryPosts from './create/createNewBreweryPosts';
import createNewUsers from './create/createNewUsers'; import createNewUsers from './create/createNewUsers';
import createNewBreweryPostLikes from './create/createNewBreweryPostLikes';
(async () => { (async () => {
try { try {
@@ -52,6 +53,10 @@ import createNewUsers from './create/createNewUsers';
numberOfLikes: 10000, numberOfLikes: 10000,
joinData: { beerPosts, users }, joinData: { beerPosts, users },
}), }),
createNewBreweryPostLikes({
numberOfLikes: 10000,
joinData: { breweryPosts, users },
}),
createNewBeerImages({ createNewBeerImages({
numberOfImages: 1000, numberOfImages: 1000,
joinData: { beerPosts, users }, joinData: { beerPosts, users },

View File

@@ -1,12 +1,8 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
const sendLikeRequest = async (beerPostId: string) => { const sendBeerPostLikeRequest = async (beerPostId: string) => {
const response = await fetch(`/api/beers/${beerPostId}/like`, { const response = await fetch(`/api/beers/${beerPostId}/like`, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: '',
}); });
if (!response.ok) { if (!response.ok) {
@@ -26,4 +22,4 @@ const sendLikeRequest = async (beerPostId: string) => {
return { success, message }; return { success, message };
}; };
export default sendLikeRequest; export default sendBeerPostLikeRequest;

View File

@@ -0,0 +1,25 @@
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
const sendBreweryPostLikeRequest = async (breweryPostId: string) => {
const response = await fetch(`/api/breweries/${breweryPostId}/like`, {
method: 'POST',
});
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('Invalid API response.');
}
if (!parsed.success) {
throw new Error('Invalid API response.');
}
const { success, message } = parsed.data;
return { success, message };
};
export default sendBreweryPostLikeRequest;

View File

@@ -4,9 +4,14 @@ import { z } from 'zod';
const prisma = DBClient.instance; const prisma = DBClient.instance;
const getAllBreweryPosts = async () => { const getAllBreweryPosts = async (pageNum?: number, pageSize?: number) => {
const skip = pageNum && pageSize ? (pageNum - 1) * pageSize : undefined;
const take = pageNum && pageSize ? pageSize : undefined;
const breweryPosts: z.infer<typeof BreweryPostQueryResult>[] = const breweryPosts: z.infer<typeof BreweryPostQueryResult>[] =
await prisma.breweryPost.findMany({ await prisma.breweryPost.findMany({
skip,
take,
select: { select: {
id: true, id: true,
location: true, location: true,

View File

@@ -27,9 +27,9 @@ const myThemes = {
warning: 'hsl(40, 76%, 73%)', warning: 'hsl(40, 76%, 73%)',
'primary-content': 'hsl(0, 0%, 0%)', 'primary-content': 'hsl(0, 0%, 0%)',
'error-content': 'hsl(0, 0%, 0%)', 'error-content': 'hsl(0, 0%, 0%)',
'base-100': 'hsl(180, 8%, 94%)', 'base-300': 'hsl(180, 10%, 88%)',
'base-200': 'hsl(180, 8%, 92%)', 'base-200': 'hsl(180, 10%, 92%)',
'base-300': 'hsl(180, 8%, 88%)', 'base-100': 'hsl(180, 10%, 95%)',
}, },
}; };