add user context and likes

This commit is contained in:
Aaron William Po
2023-02-08 07:43:59 -05:00
parent 20000cc4af
commit f6880deeb6
12 changed files with 300 additions and 47 deletions

View File

@@ -1,20 +1,84 @@
import Link from 'next/link'; import Link from 'next/link';
import formatDistanceStrict from 'date-fns/formatDistanceStrict'; import formatDistanceStrict from 'date-fns/formatDistanceStrict';
import format from 'date-fns/format'; import format from 'date-fns/format';
import { useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { FaRegThumbsUp, FaThumbsUp } from 'react-icons/fa'; import { FaRegThumbsUp, FaThumbsUp } from 'react-icons/fa';
import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult'; import BeerPostQueryResult from '@/services/BeerPost/schema/BeerPostQueryResult';
import UserContext from '@/pages/contexts/userContext';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
const BeerInfoHeader: React.FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost }) => { const BeerInfoHeader: React.FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost }) => {
const createdAtDate = new Date(beerPost.createdAt); const createdAtDate = new Date(beerPost.createdAt);
const [timeDistance, setTimeDistance] = useState(''); const [timeDistance, setTimeDistance] = useState('');
const { user } = useContext(UserContext);
useEffect(() => { useEffect(() => {
setTimeDistance(formatDistanceStrict(new Date(beerPost.createdAt), new Date())); setTimeDistance(formatDistanceStrict(new Date(beerPost.createdAt), new Date()));
}, [beerPost.createdAt]); }, [beerPost.createdAt]);
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
useEffect(() => {
if (!user) {
setLoading(false);
return;
}
fetch(`/api/beers/${beerPost.id}/like/is-liked`)
.then((response) => response.json())
.then((data) => {
const parsed = APIResponseValidationSchema.safeParse(data);
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 payload.');
}
const { isLiked: alreadyLiked } = parsedPayload.data;
setIsLiked(alreadyLiked);
setLoading(false);
});
}, [user, beerPost.id]);
const handleLike = async () => {
const response = await fetch(`/api/beers/${beerPost.id}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: '',
});
if (!response.ok) {
throw new Error('Something went wrong.');
}
const data = await response.json();
const parsed = APIResponseValidationSchema.safeParse(data);
if (!parsed.success) {
throw new Error('Invalid API response.');
}
const { success, message } = parsed.data;
setIsLiked(!isLiked);
console.log({ success, message });
};
return ( return (
<div className="card flex flex-col justify-center bg-base-300"> <div className="card flex flex-col justify-center bg-base-300">
<div className="card-body"> <div className="card-body">
@@ -59,14 +123,16 @@ const BeerInfoHeader: React.FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost
</div> </div>
</div> </div>
<div className="card-actions"> <div className="card-actions">
{user && (
<button <button
type="button" type="button"
className={`btn gap-2 rounded-2xl ${ className={`btn gap-2 rounded-2xl ${
!isLiked ? 'btn-ghost outline' : 'btn-primary' !isLiked ? 'btn-ghost outline' : 'btn-primary'
}`} }`}
onClick={() => { onClick={() => {
setIsLiked(!isLiked); handleLike();
}} }}
disabled={loading}
> >
{isLiked ? ( {isLiked ? (
<> <>
@@ -80,6 +146,7 @@ const BeerInfoHeader: React.FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost
</> </>
)} )}
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ interface FormTextAreaProps {
error: boolean; error: boolean;
id: string; id: string;
rows: number; rows: number;
disabled?: boolean;
} }
/** /**
@@ -17,6 +18,7 @@ interface FormTextAreaProps {
* error={true} * error={true}
* placeholder="Test" * placeholder="Test"
* rows={5} * rows={5}
* disabled
* />; * />;
* *
* @param props * @param props
@@ -25,6 +27,7 @@ interface FormTextAreaProps {
* @param props.error Whether or not the textarea has an error. * @param props.error Whether or not the textarea has an error.
* @param props.id The id of the textarea. * @param props.id The id of the textarea.
* @param props.rows The number of rows to display in the textarea. * @param props.rows The number of rows to display in the textarea.
* @param props.disabled Whether or not the textarea is disabled.
*/ */
const FormTextArea: FunctionComponent<FormTextAreaProps> = ({ const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
placeholder = '', placeholder = '',
@@ -32,6 +35,7 @@ const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
error, error,
id, id,
rows, rows,
disabled = false,
}) => ( }) => (
<textarea <textarea
id={id} id={id}
@@ -41,6 +45,7 @@ const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
}`} }`}
{...formValidationSchema} {...formValidationSchema}
rows={rows} rows={rows}
disabled={disabled}
/> />
); );

View File

@@ -10,6 +10,7 @@ interface FormInputProps {
type: 'email' | 'password' | 'text' | 'date'; type: 'email' | 'password' | 'text' | 'date';
id: string; id: string;
height?: string; height?: string;
disabled?: boolean;
} }
/** /**
@@ -20,6 +21,7 @@ interface FormInputProps {
* error={!!errors.name} * error={!!errors.name}
* type="text" * type="text"
* id="name" * id="name"
* disabled
* />; * />;
* *
* @param param0 The props for the FormTextInput component * @param param0 The props for the FormTextInput component
@@ -30,6 +32,7 @@ interface FormInputProps {
* @param param0.type The input type (email, password, text, date). * @param param0.type The input type (email, password, text, date).
* @param param0.id The id of the input. * @param param0.id The id of the input.
* @param param0.height The height of the input. * @param param0.height The height of the input.
* @param param0.disabled Whether or not the input is disabled.
*/ */
const FormTextInput: FunctionComponent<FormInputProps> = ({ const FormTextInput: FunctionComponent<FormInputProps> = ({
placeholder = '', placeholder = '',
@@ -37,6 +40,7 @@ const FormTextInput: FunctionComponent<FormInputProps> = ({
error, error,
type, type,
id, id,
disabled = false,
}) => ( }) => (
<input <input
id={id} id={id}
@@ -46,6 +50,7 @@ const FormTextInput: FunctionComponent<FormInputProps> = ({
error ? 'input-error' : '' error ? 'input-error' : ''
}`} }`}
{...formValidationSchema} {...formValidationSchema}
disabled={disabled}
/> />
); );

View File

@@ -2,7 +2,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import { Options } from 'next-connect'; import { Options } from 'next-connect';
import { z } from 'zod'; import { z } from 'zod';
import logger from '../pino/logger';
import ServerError from '../util/ServerError'; import ServerError from '../util/ServerError';
const NextConnectConfig: Options< const NextConnectConfig: Options<
@@ -17,7 +17,6 @@ const NextConnectConfig: Options<
}); });
}, },
onError(error, req, res) { onError(error, req, res) {
logger.error(error);
const message = error instanceof Error ? error.message : 'Internal server error.'; const message = error instanceof Error ? error.message : 'Internal server error.';
const statusCode = error instanceof ServerError ? error.statusCode : 500; const statusCode = error instanceof ServerError ? error.statusCode : 500;
res.status(statusCode).json({ res.status(statusCode).json({

View File

@@ -1,6 +1,14 @@
import useUser from '@/hooks/useUser';
import '@/styles/globals.css'; import '@/styles/globals.css';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
import UserContext from './contexts/userContext';
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />; const { user, isLoading, error } = useUser();
return (
<UserContext.Provider value={{ user, isLoading, error }}>
<Component {...pageProps} />
</UserContext.Provider>
);
} }

View File

@@ -0,0 +1,72 @@
import DBClient from '@/prisma/DBClient';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import getBeerPostById from '@/services/BeerPost/getBeerPostById';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import validateRequest from '@/config/zod/middleware/validateRequest';
import getCurrentUser from '@/config/auth/middleware/getCurrentUser';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import nextConnect from 'next-connect';
import { z } from 'zod';
import { NextApiResponse } from 'next';
import ServerError from '@/config/util/ServerError';
const likeBeerPost = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const user = req.user!;
const id = req.query.id as string;
const beer = await getBeerPostById(id);
if (!beer) {
throw new ServerError('Could not find a beer post with that id', 404);
}
const alreadyLiked = await DBClient.instance.beerPostLikes.findFirst({
where: {
beerPostId: id,
userId: user.id,
},
});
if (alreadyLiked) {
await DBClient.instance.beerPostLikes.delete({
where: {
id: alreadyLiked.id,
},
});
res.status(200).json({
success: true,
message: 'Successfully unliked beer post',
statusCode: 200,
});
return;
}
await DBClient.instance.beerPostLikes.create({
data: {
beerPost: { connect: { id } },
user: { connect: { id: user.id } },
},
});
res.status(200).json({
success: true,
message: 'Successfully liked beer post',
statusCode: 200,
});
};
const handler = nextConnect(NextConnectConfig).post(
getCurrentUser,
validateRequest({
querySchema: z.object({
id: z.string().uuid(),
}),
}),
likeBeerPost,
);
export default handler;

View File

@@ -0,0 +1,45 @@
import getCurrentUser from '@/config/auth/middleware/getCurrentUser';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectConfig from '@/config/nextConnect/NextConnectConfig';
import validateRequest from '@/config/zod/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import nextConnect 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.beerPostLikes.findFirst({
where: {
beerPostId: id,
userId: user.id,
},
});
res.status(200).json({
success: true,
message: 'Successfully checked if beer post is liked by the current user',
statusCode: 200,
payload: {
isLiked: !!alreadyLiked,
},
});
};
const handler = nextConnect(NextConnectConfig).get(
getCurrentUser,
validateRequest({
querySchema: z.object({
id: z.string().uuid(),
}),
}),
checkIfLiked,
);
export default handler;

View File

@@ -12,8 +12,8 @@ import { BeerPost } from '@prisma/client';
import { NextPage, GetServerSideProps } from 'next'; import { NextPage, GetServerSideProps } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import Image from 'next/image'; import Image from 'next/image';
import { useState, useEffect, useContext } from 'react';
import { useEffect, useState } from 'react'; import UserContext from '../contexts/userContext';
interface BeerPageProps { interface BeerPageProps {
beerPost: BeerPostQueryResult; beerPost: BeerPostQueryResult;
@@ -36,10 +36,13 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
beerRecommendations, beerRecommendations,
beerComments, beerComments,
}) => { }) => {
const { user } = useContext(UserContext);
const [comments, setComments] = useState(beerComments); const [comments, setComments] = useState(beerComments);
useEffect(() => { useEffect(() => {
setComments(beerComments); setComments(beerComments);
}, [beerComments]); }, [beerComments]);
return ( return (
<Layout> <Layout>
<Head> <Head>
@@ -63,8 +66,16 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
<div className="mt-4 flex space-x-3"> <div className="mt-4 flex space-x-3">
<div className="w-[60%] space-y-3"> <div className="w-[60%] space-y-3">
<div className="card h-96 bg-base-300"> <div className="card h-96 bg-base-300">
<div className="card-body"> <div className="card-body h-full">
{user ? (
<BeerCommentForm beerPost={beerPost} setComments={setComments} /> <BeerCommentForm beerPost={beerPost} setComments={setComments} />
) : (
<div className="flex h-full flex-col items-center justify-center">
<span className="text-lg font-bold">
Log in to leave a comment.
</span>
</div>
)}
</div> </div>
</div> </div>
<div className="card h-[135rem] bg-base-300"> <div className="card h-[135rem] bg-base-300">

View File

@@ -0,0 +1,11 @@
import GetUserSchema from '@/services/user/schema/GetUserSchema';
import { createContext } from 'react';
import { z } from 'zod';
const UserContext = createContext<{
user?: z.infer<typeof GetUserSchema>;
error?: unknown;
isLoading: boolean;
}>({ isLoading: true });
export default UserContext;

View File

@@ -1,11 +1,13 @@
import Layout from '@/components/ui/Layout'; import Layout from '@/components/ui/Layout';
import Spinner from '@/components/ui/Spinner'; import Spinner from '@/components/ui/Spinner';
import withPageAuthRequired from '@/config/auth/withPageAuthRequired'; import withPageAuthRequired from '@/config/auth/withPageAuthRequired';
import useUser from '@/hooks/useUser';
import { GetServerSideProps, NextPage } from 'next'; import { GetServerSideProps, NextPage } from 'next';
import { useContext } from 'react';
import UserContext from '../contexts/userContext';
const ProtectedPage: NextPage = () => { const ProtectedPage: NextPage = () => {
const { user, isLoading, error } = useUser(); const { user, error, isLoading } = useContext(UserContext);
return ( return (
<Layout> <Layout>

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "BeerPostLikes" (
"id" TEXT NOT NULL,
"beerPostId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3),
CONSTRAINT "BeerPostLikes_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "BeerPostLikes" ADD CONSTRAINT "BeerPostLikes_beerPostId_fkey" FOREIGN KEY ("beerPostId") REFERENCES "BeerPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BeerPostLikes" ADD CONSTRAINT "BeerPostLikes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -25,6 +25,7 @@ model User {
breweryPosts BreweryPost[] breweryPosts BreweryPost[]
beerComments BeerComment[] beerComments BeerComment[]
breweryComments BreweryComment[] breweryComments BreweryComment[]
BeerPostLikes BeerPostLikes[]
} }
model BeerPost { model BeerPost {
@@ -43,6 +44,17 @@ model BeerPost {
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
beerComments BeerComment[] beerComments BeerComment[]
beerImages BeerImage[] beerImages BeerImage[]
BeerPostLikes BeerPostLikes[]
}
model BeerPostLikes {
id String @id @default(uuid())
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
} }
model BeerComment { model BeerComment {