diff --git a/package-lock.json b/package-lock.json index 6fc6c77..1ab0150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.17.12", "@uploadthing/react": "^4.1.3", + "@upstash/redis": "^1.28.1", "axios": "^1.6.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -1767,6 +1768,14 @@ } } }, + "node_modules/@upstash/redis": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.28.1.tgz", + "integrity": "sha512-px7x2ZP/Tn5HZg0GbM4sf/+LMExrR8zmGAAGeOvptB/5wgTvaease7RdyoEmsb/PwL5GjlJy3xWmy4TsGk9s4w==", + "dependencies": { + "crypto-js": "^4.2.0" + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -2384,6 +2393,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index 13f05ce..4af6deb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.17.12", "@uploadthing/react": "^4.1.3", + "@upstash/redis": "^1.28.1", "axios": "^1.6.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/src/app/api/subreddit/post/vote/route.ts b/src/app/api/subreddit/post/vote/route.ts new file mode 100644 index 0000000..d1a8175 --- /dev/null +++ b/src/app/api/subreddit/post/vote/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server"; +import * as z from 'zod'; + +import { getAuthSession } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { redis } from "@/lib/redis"; +import { PostVoteValidator } from "@/lib/validators/vote"; +import { CachedPost } from "@/types/redis"; + +const CACHE_AFTER_UPVOTES = 1 + +export async function PATCH(req: Request) { + try { + const body = await req.json() + const { postId, voteType } = PostVoteValidator.parse(body) + const session = await getAuthSession() + + if (!session?.user) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + // check if user has already voted on this post + const existingVote = await db.vote.findFirst({ + where: { + userId: session.user.id, + postId, + }, + }) + + const post = await db.post.findUnique({ + where: { + id: postId, + }, + include: { + author: true, + votes: true, + }, + }) + + if (!post) { + return new NextResponse('Post not found', { status: 404 }) + } + + if (existingVote) { + // if vote type is the same as existing vote, delete the vote + if (existingVote.type === voteType) { + + await db.vote.delete({ + where: { + userId_postId: { + postId, + userId: session.user.id, + }, + }, + }) + + //recount the votes + const votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === 'UP') return acc + 1 + if (vote.type === 'DOWN') return acc - 1 + return acc + }, 0) + + if (votesAmt > CACHE_AFTER_UPVOTES) { + const cachePayload: CachedPost = { + id: post.id, + authorUsername: post.author.username ?? "", + content: JSON.stringify(post.content), + title: post.title, + currentVote: voteType, + createdAt: post.createdAt + } + await redis.hset(`post:${postId}`, cachePayload) + } + return new NextResponse('OK') + } + + // if vote type is different, update the vote + await db.vote.update({ + where: { + userId_postId: { + postId, + userId: session.user.id, + }, + }, + data: { + type: voteType, + }, + }) + + //recount the votes + const votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === 'UP') return acc + 1 + if (vote.type === 'DOWN') return acc - 1 + return acc + }, 0) + + if (votesAmt > CACHE_AFTER_UPVOTES) { + const cachePayload: CachedPost = { + id: post.id, + authorUsername: post.author.username ?? "", + content: JSON.stringify(post.content), + title: post.title, + currentVote: voteType, + createdAt: post.createdAt + } + await redis.hset(`post:${postId}`, cachePayload) + } + return new NextResponse('OK') + } + + //if not existing, create a new vote type + await db.vote.create({ + data: { + type: voteType, + userId: session.user.id, + postId, + }, + }) + + // Recount the votes + const votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === 'UP') return acc + 1 + if (vote.type === 'DOWN') return acc - 1 + return acc + }, 0) + + if (votesAmt >= CACHE_AFTER_UPVOTES) { + const cachePayload: CachedPost = { + authorUsername: post.author.username ?? '', + content: JSON.stringify(post.content), + id: post.id, + title: post.title, + currentVote: voteType, + createdAt: post.createdAt, + } + + await redis.hset(`post:${postId}`, cachePayload) // Store the post data as a hash + } + return new NextResponse('OK') + } catch (error) { + (error) + if (error instanceof z.ZodError) { + return new NextResponse(error.message, { status: 400 }) + } + + return new NextResponse( + 'Could not vote at this time. Please try later', + { status: 500 } + ) + } +} + diff --git a/src/components/post-feed.tsx b/src/components/post-feed.tsx index 108e811..141ba75 100644 --- a/src/components/post-feed.tsx +++ b/src/components/post-feed.tsx @@ -4,7 +4,7 @@ import { useInfiniteQuery } from "@tanstack/react-query" import axios from 'axios' import { ExtendedPost } from "@/types/db" -import { useIntersection } from "@/hooks/use-intersection" +import { useIntersection } from "@/hooks/@mantine-hooks/use-intersection" import { INFINITE_SCROLLING_PAGINATION_RESULTS } from "../../config" import Post from "./post" @@ -73,6 +73,8 @@ export function PostFeed({ initialPosts, subredditName, userId }: PostFeedProps) subredditName={post.subreddit.name} post={post} commentAmt={post.comments.length} + votesAmt={totalVotes} + currentVote={currentVote} /> ) @@ -83,6 +85,8 @@ export function PostFeed({ initialPosts, subredditName, userId }: PostFeedProps) subredditName={post.subreddit.name} post={post} commentAmt={post.comments.length} + votesAmt={totalVotes} + currentVote={currentVote} /> ) diff --git a/src/components/post-vote/post-vote-client.tsx b/src/components/post-vote/post-vote-client.tsx new file mode 100644 index 0000000..01fb791 --- /dev/null +++ b/src/components/post-vote/post-vote-client.tsx @@ -0,0 +1,95 @@ +"use client" + +import { usePrevious } from "@/hooks/@mantine-hooks/use-previous"; +import { useCustomToast } from "@/hooks/use-custom-tost"; +import { VoteType } from "@prisma/client"; +import { useEffect, useState } from "react"; +import { Button } from "../ui/Button"; +import { ArrowBigDown, ArrowBigUp } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { PostVoteRequest } from "@/lib/validators/vote"; +import axios, { AxiosError } from "axios"; +import { toast } from "@/hooks/use-toast"; + +interface PostVoteClientProps { + postId: string; + initialVotesAmount: number; + initialVote?: VoteType | null +} + +export function PostVoteClient({ postId, initialVotesAmount, initialVote }: PostVoteClientProps) { + const { logInToast } = useCustomToast() + const [votesAmount, setVotesAmount] = useState(initialVotesAmount) + const [currentVote, setCurrentVote] = useState(initialVote) + const prevVote = usePrevious(currentVote) + + useEffect(() => { + setCurrentVote(initialVote) + }, [initialVote]) + + const { mutate: vote } = useMutation({ + mutationFn: async (voteType: VoteType) => { + const payload: PostVoteRequest = { + postId, + voteType + } + await axios.patch('/api/subreddit/post/vote', payload) + }, + + onError: (err, voteType) => { + if (voteType === 'UP') setVotesAmount((prev) => prev - 1) + else setVotesAmount((prev) => prev + 1) + + // reset current vote + setCurrentVote(prevVote) + + if (err instanceof AxiosError) { + if (err.response?.status === 401) { + return logInToast() + } + } + + return toast({ + title: 'Something went wrong.', + description: 'Your vote was not registered. Please try again.', + variant: 'destructive', + }) + }, + + onMutate: (type: VoteType) => { + if (currentVote === type) { + // User is voting the same way again, so remove their vote + setCurrentVote(undefined) + if (type === 'UP') setVotesAmount((prev) => prev - 1) + else if (type === 'DOWN') setVotesAmount((prev) => prev + 1) + } else { + // User is voting in the opposite direction, so subtract 2 + setCurrentVote(type) + if (type === 'UP') setVotesAmount((prev) => prev + (currentVote ? 2 : 1)) + else if (type === 'DOWN') + setVotesAmount((prev) => prev - (currentVote ? 2 : 1)) + } + }, + }) + + return ( +
+ +

{votesAmount}

+ +
+ ) +} diff --git a/src/components/post.tsx b/src/components/post.tsx index 42367ee..2508675 100644 --- a/src/components/post.tsx +++ b/src/components/post.tsx @@ -5,22 +5,32 @@ import { ExtendedPost } from "@/types/db"; import Link from "next/link"; import { MessageSquare } from "lucide-react"; import { EditorOutput } from "./editor-output"; +import { PostVoteClient } from "./post-vote/post-vote-client" +import { Vote } from "@prisma/client"; + +type PartialVote = Pick interface PostProps { - subredditName: string, - post: ExtendedPost, - commentAmt: number + subredditName: string; + post: ExtendedPost; + commentAmt: number; + votesAmt: number; + currentVote?: PartialVote } -const PostPage = ({ subredditName, post, commentAmt }: PostProps) => { +const PostPage = ({ subredditName, post, commentAmt, votesAmt, currentVote }: PostProps) => { const pRef = useRef(null) return (
-
- {/* TODO: post votes */} +
+ -
+
{ subredditName ? ( diff --git a/src/hooks/use-intersection.tsx b/src/hooks/@mantine-hooks/use-intersection.ts similarity index 100% rename from src/hooks/use-intersection.tsx rename to src/hooks/@mantine-hooks/use-intersection.ts diff --git a/src/hooks/@mantine-hooks/use-previous.ts b/src/hooks/@mantine-hooks/use-previous.ts new file mode 100644 index 0000000..9fff0ec --- /dev/null +++ b/src/hooks/@mantine-hooks/use-previous.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} \ No newline at end of file diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..bf22372 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,9 @@ +import { Redis } from '@upstash/redis' + + +const redis = new Redis({ + url: process.env.REDIS_URL!, + token: process.env.REDIS_SECRET! +}) + +export { redis } \ No newline at end of file diff --git a/src/lib/validators/vote.ts b/src/lib/validators/vote.ts new file mode 100644 index 0000000..d13dfba --- /dev/null +++ b/src/lib/validators/vote.ts @@ -0,0 +1,16 @@ +import z from 'zod' + +export const PostVoteValidator = z.object({ + postId: z.string(), + voteType: z.enum(['UP', 'DOWN']) +}) + +export type PostVoteRequest = z.infer + + +export const CommentVoteValidator = z.object({ + commentId: z.string(), + voteType: z.enum(['UP', 'DOWN']) +}) + +export type CommentVoteRequest = z.infer \ No newline at end of file diff --git a/src/types/redis.d.ts b/src/types/redis.d.ts new file mode 100644 index 0000000..d995289 --- /dev/null +++ b/src/types/redis.d.ts @@ -0,0 +1,10 @@ +import { Vote } from "@prisma/client"; + +export type CachedPost = { + id: string; + title: string; + authorUsername: string; + content: string; + currentVote: Vote['type'] | null; + createdAt: Date +} \ No newline at end of file