mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
add user context and likes
This commit is contained in:
@@ -1,20 +1,84 @@
|
||||
import Link from 'next/link';
|
||||
import formatDistanceStrict from 'date-fns/formatDistanceStrict';
|
||||
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 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 createdAtDate = new Date(beerPost.createdAt);
|
||||
const [timeDistance, setTimeDistance] = useState('');
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
useEffect(() => {
|
||||
setTimeDistance(formatDistanceStrict(new Date(beerPost.createdAt), new Date()));
|
||||
}, [beerPost.createdAt]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 (
|
||||
<div className="card flex flex-col justify-center bg-base-300">
|
||||
<div className="card-body">
|
||||
@@ -59,27 +123,30 @@ const BeerInfoHeader: React.FC<{ beerPost: BeerPostQueryResult }> = ({ beerPost
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn gap-2 rounded-2xl ${
|
||||
!isLiked ? 'btn-ghost outline' : 'btn-primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setIsLiked(!isLiked);
|
||||
}}
|
||||
>
|
||||
{isLiked ? (
|
||||
<>
|
||||
<FaThumbsUp className="text-2xl" />
|
||||
<span>Liked</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegThumbsUp className="text-2xl" />
|
||||
<span>Like</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn gap-2 rounded-2xl ${
|
||||
!isLiked ? 'btn-ghost outline' : 'btn-primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleLike();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{isLiked ? (
|
||||
<>
|
||||
<FaThumbsUp className="text-2xl" />
|
||||
<span>Liked</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegThumbsUp className="text-2xl" />
|
||||
<span>Like</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface FormTextAreaProps {
|
||||
error: boolean;
|
||||
id: string;
|
||||
rows: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,7 @@ interface FormTextAreaProps {
|
||||
* error={true}
|
||||
* placeholder="Test"
|
||||
* rows={5}
|
||||
* disabled
|
||||
* />;
|
||||
*
|
||||
* @param props
|
||||
@@ -25,6 +27,7 @@ interface FormTextAreaProps {
|
||||
* @param props.error Whether or not the textarea has an error.
|
||||
* @param props.id The id of 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> = ({
|
||||
placeholder = '',
|
||||
@@ -32,6 +35,7 @@ const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
|
||||
error,
|
||||
id,
|
||||
rows,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<textarea
|
||||
id={id}
|
||||
@@ -41,6 +45,7 @@ const FormTextArea: FunctionComponent<FormTextAreaProps> = ({
|
||||
}`}
|
||||
{...formValidationSchema}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface FormInputProps {
|
||||
type: 'email' | 'password' | 'text' | 'date';
|
||||
id: string;
|
||||
height?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ interface FormInputProps {
|
||||
* error={!!errors.name}
|
||||
* type="text"
|
||||
* id="name"
|
||||
* disabled
|
||||
* />;
|
||||
*
|
||||
* @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.id The id 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> = ({
|
||||
placeholder = '',
|
||||
@@ -37,6 +40,7 @@ const FormTextInput: FunctionComponent<FormInputProps> = ({
|
||||
error,
|
||||
type,
|
||||
id,
|
||||
disabled = false,
|
||||
}) => (
|
||||
<input
|
||||
id={id}
|
||||
@@ -46,6 +50,7 @@ const FormTextInput: FunctionComponent<FormInputProps> = ({
|
||||
error ? 'input-error' : ''
|
||||
}`}
|
||||
{...formValidationSchema}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Options } from 'next-connect';
|
||||
import { z } from 'zod';
|
||||
import logger from '../pino/logger';
|
||||
|
||||
import ServerError from '../util/ServerError';
|
||||
|
||||
const NextConnectConfig: Options<
|
||||
@@ -17,7 +17,6 @@ const NextConnectConfig: Options<
|
||||
});
|
||||
},
|
||||
onError(error, req, res) {
|
||||
logger.error(error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error.';
|
||||
const statusCode = error instanceof ServerError ? error.statusCode : 500;
|
||||
res.status(statusCode).json({
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import useUser from '@/hooks/useUser';
|
||||
import '@/styles/globals.css';
|
||||
import type { AppProps } from 'next/app';
|
||||
import UserContext from './contexts/userContext';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
72
pages/api/beers/[id]/like/index.ts
Normal file
72
pages/api/beers/[id]/like/index.ts
Normal 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;
|
||||
45
pages/api/beers/[id]/like/is-liked.ts
Normal file
45
pages/api/beers/[id]/like/is-liked.ts
Normal 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;
|
||||
@@ -12,8 +12,8 @@ import { BeerPost } from '@prisma/client';
|
||||
import { NextPage, GetServerSideProps } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect, useContext } from 'react';
|
||||
import UserContext from '../contexts/userContext';
|
||||
|
||||
interface BeerPageProps {
|
||||
beerPost: BeerPostQueryResult;
|
||||
@@ -36,10 +36,13 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
|
||||
beerRecommendations,
|
||||
beerComments,
|
||||
}) => {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const [comments, setComments] = useState(beerComments);
|
||||
useEffect(() => {
|
||||
setComments(beerComments);
|
||||
}, [beerComments]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
@@ -63,8 +66,16 @@ const BeerByIdPage: NextPage<BeerPageProps> = ({
|
||||
<div className="mt-4 flex space-x-3">
|
||||
<div className="w-[60%] space-y-3">
|
||||
<div className="card h-96 bg-base-300">
|
||||
<div className="card-body">
|
||||
<BeerCommentForm beerPost={beerPost} setComments={setComments} />
|
||||
<div className="card-body h-full">
|
||||
{user ? (
|
||||
<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 className="card h-[135rem] bg-base-300">
|
||||
|
||||
11
pages/contexts/userContext.ts
Normal file
11
pages/contexts/userContext.ts
Normal 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;
|
||||
@@ -1,11 +1,13 @@
|
||||
import Layout from '@/components/ui/Layout';
|
||||
import Spinner from '@/components/ui/Spinner';
|
||||
import withPageAuthRequired from '@/config/auth/withPageAuthRequired';
|
||||
import useUser from '@/hooks/useUser';
|
||||
|
||||
import { GetServerSideProps, NextPage } from 'next';
|
||||
import { useContext } from 'react';
|
||||
import UserContext from '../contexts/userContext';
|
||||
|
||||
const ProtectedPage: NextPage = () => {
|
||||
const { user, isLoading, error } = useUser();
|
||||
const { user, error, isLoading } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
||||
16
prisma/migrations/20230208021759_/migration.sql
Normal file
16
prisma/migrations/20230208021759_/migration.sql
Normal 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;
|
||||
@@ -25,24 +25,36 @@ model User {
|
||||
breweryPosts BreweryPost[]
|
||||
beerComments BeerComment[]
|
||||
breweryComments BreweryComment[]
|
||||
BeerPostLikes BeerPostLikes[]
|
||||
}
|
||||
|
||||
model BeerPost {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
ibu Float
|
||||
abv Float
|
||||
description String
|
||||
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
|
||||
postedById String
|
||||
brewery BreweryPost @relation(fields: [breweryId], references: [id], onDelete: Cascade)
|
||||
breweryId String
|
||||
type BeerType @relation(fields: [typeId], references: [id], onDelete: Cascade)
|
||||
typeId String
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
|
||||
beerComments BeerComment[]
|
||||
beerImages BeerImage[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
ibu Float
|
||||
abv Float
|
||||
description String
|
||||
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
|
||||
postedById String
|
||||
brewery BreweryPost @relation(fields: [breweryId], references: [id], onDelete: Cascade)
|
||||
breweryId String
|
||||
type BeerType @relation(fields: [typeId], references: [id], onDelete: Cascade)
|
||||
typeId String
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
|
||||
beerComments BeerComment[]
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user