From dc1ec49a1f1d0ec03279d2dd86ebf7124606cec2 Mon Sep 17 00:00:00 2001 From: joel Date: Thu, 31 Oct 2024 12:53:20 -0700 Subject: [PATCH] feat: yolo in likes --- src/components/LikeButton.tsx | 87 ++++++++++++++++++++++++++ src/lib/likes.ts | 111 ++++++++++++++++++++++++++++++++++ src/pages/[post].tsx | 20 +++--- src/server/routers/_app.ts | 2 + src/server/routers/likes.ts | 99 ++++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 src/components/LikeButton.tsx create mode 100644 src/lib/likes.ts create mode 100644 src/server/routers/likes.ts diff --git a/src/components/LikeButton.tsx b/src/components/LikeButton.tsx new file mode 100644 index 000000000..fcf0742d3 --- /dev/null +++ b/src/components/LikeButton.tsx @@ -0,0 +1,87 @@ +import {trpc} from '@/app/_trpc/client' +import {cn} from '@/ui/utils' +import {ThumbsUp} from 'lucide-react' +import {useEffect, useState} from 'react' + +interface LikeButtonProps { + postId: number + className?: string +} + +export function LikeButton({postId, className}: LikeButtonProps) { + const utils = trpc.useUtils() + const [mounted, setMounted] = useState(false) + const [optimisticLiked, setOptimisticLiked] = useState(false) + const [optimisticCount, setOptimisticCount] = useState(null) + + const { + data: likeCount, + isLoading: countLoading, + status: countStatus, + } = trpc.likes.getLikesForPost.useQuery({postId}) + const { + data: hasLiked, + isLoading: likedLoading, + status: likedStatus, + } = trpc.likes.hasUserLikedPost.useQuery({postId}) + + const {mutate: toggleLike, isLoading: mutationLoading} = + trpc.likes.toggleLike.useMutation({ + onMutate: () => { + setOptimisticLiked(!optimisticLiked) + setOptimisticCount((prev) => + prev !== null ? (optimisticLiked ? prev - 1 : prev + 1) : null, + ) + }, + onSettled: () => { + utils.likes.getLikesForPost.invalidate({postId}) + utils.likes.hasUserLikedPost.invalidate({postId}) + }, + onError: () => { + setOptimisticLiked(!optimisticLiked) + setOptimisticCount((prev) => + prev !== null ? (optimisticLiked ? prev + 1 : prev - 1) : null, + ) + }, + }) + + useEffect(() => { + setMounted(true) + }, []) + + useEffect(() => { + if (hasLiked !== undefined) setOptimisticLiked(hasLiked) + }, [hasLiked]) + + useEffect(() => { + if (likeCount !== undefined) setOptimisticCount(likeCount) + }, [likeCount]) + + const isLoading = countStatus === 'loading' || likedStatus === 'loading' + + return ( + + ) +} diff --git a/src/lib/likes.ts b/src/lib/likes.ts new file mode 100644 index 000000000..7aaa8d89a --- /dev/null +++ b/src/lib/likes.ts @@ -0,0 +1,111 @@ +import {pgQuery} from '@/db' + +export type LikeResponse = { + success: boolean + error?: string + count?: number +} + +export async function addLikeToPostForUser({ + postId, + userId, +}: { + postId: number + userId: string +}): Promise { + try { + const result = await pgQuery( + `INSERT INTO reactions ( + likeable_id, + likeable_type, + reaction_type, + user_id, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (user_id, likeable_type, likeable_id, reaction_type) + DO NOTHING + RETURNING *`, + [postId, 'Lesson', 'like', userId], + ) + return { + success: result.rowCount !== null && result.rowCount > 0, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } + } +} + +export async function removeLikeFromPostForUser({ + postId, + userId, +}: { + postId: number + userId: string +}): Promise { + try { + const result = await pgQuery( + `DELETE FROM reactions + WHERE likeable_id = $1 + AND likeable_type = $2 + AND user_id = $3 + AND reaction_type = $4`, + [postId, 'Lesson', userId, 'like'], + ) + return { + success: result.rowCount !== null && result.rowCount > 0, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } + } +} + +export async function getLikesForPost({postId}: {postId: number}) { + const result = await pgQuery( + `SELECT COUNT(*) as count + FROM reactions + WHERE likeable_id = $1 + AND likeable_type = $2 + AND reaction_type = $3`, + [postId, 'Lesson', 'like'], + ) + return parseInt(result.rows[0].count, 10) +} + +export async function hasUserLikedPost({ + postId, + userId, +}: { + postId: number + userId: string +}) { + const result = await pgQuery( + `SELECT EXISTS( + SELECT 1 FROM reactions + WHERE likeable_id = $1 + AND likeable_type = $2 + AND user_id = $3 + AND reaction_type = $4 + ) as liked`, + [postId, 'Lesson', userId, 'like'], + ) + return result.rows[0].liked +} + +export async function getLikedPostIdsForUser({userId}: {userId: string}) { + const result = await pgQuery( + `SELECT likeable_id + FROM reactions + WHERE user_id = $1 + AND likeable_type = $2 + AND reaction_type = $3`, + [userId, 'Lesson', 'like'], + ) + return result.rows.map((row) => row.likeable_id) +} diff --git a/src/pages/[post].tsx b/src/pages/[post].tsx index efdd36409..c8d807186 100644 --- a/src/pages/[post].tsx +++ b/src/pages/[post].tsx @@ -7,7 +7,7 @@ import * as React from 'react' import {MDXRemote} from 'next-mdx-remote' import mdxComponents from '@/components/mdx' import 'highlight.js/styles/night-owl.css' -import {serialize} from 'next-mdx-remote/serialize' + import {isEmpty, truncate} from 'lodash' import removeMarkdown from 'remove-markdown' import ReactMarkdown from 'react-markdown' @@ -20,10 +20,11 @@ import MuxPlayerElement from '@mux/mux-player' import {MaxResolution, MinResolution} from '@mux/playback-core' import serializeMDX from '@/markdown/serialize-mdx' import Link from 'next/link' -import Share from '@/components/share' + import TweetResource from '@/components/tweet-resource' import CopyToClipboard from '@/components/copy-resource' import {track} from '@/utils/analytics' +import {LikeButton} from '@/components/LikeButton' const access: ConnectionOptions = { uri: process.env.COURSE_BUILDER_DATABASE_URL, @@ -326,15 +327,18 @@ export default function PostPage({

)} -
-

+
+

{post.fields.title}

-
-
- +
+
+
-
+
+
+ +
diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index d1ae7ce08..e8ba516b8 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -11,6 +11,7 @@ import {topicRouter} from './topics' import {customerIORouter} from './customer-io' import {tipsRouter} from './tips' import {lessonRouter} from './lesson' +import {likesRouter} from './likes' export const appRouter = router({ healthcheck: baseProcedure.query(() => 'yay!'), @@ -23,6 +24,7 @@ export const appRouter = router({ customerIO: customerIORouter, tips: tipsRouter, lesson: lessonRouter, + likes: likesRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/routers/likes.ts b/src/server/routers/likes.ts new file mode 100644 index 000000000..85d64d45d --- /dev/null +++ b/src/server/routers/likes.ts @@ -0,0 +1,99 @@ +import {z} from 'zod' +import {router, baseProcedure} from '../trpc' +import { + addLikeToPostForUser, + removeLikeFromPostForUser, + getLikesForPost, + hasUserLikedPost, + getLikedPostIdsForUser, + type LikeResponse, +} from '@/lib/likes' +import {loadUser} from '@/lib/current-user' + +export const likesRouter = router({ + getLikesForPost: baseProcedure + .input(z.object({postId: z.number()})) + .query(async ({input}) => { + return getLikesForPost({postId: input.postId}) + }), + + addLikeToPost: baseProcedure + .input(z.object({postId: z.number()})) + .mutation(async ({input, ctx}): Promise => { + const token = ctx?.userToken + if (!token) return {success: false, error: 'Unauthorized'} + + const user = await loadUser(token) + + return addLikeToPostForUser({ + postId: input.postId, + userId: user.id, + }) + }), + + removeLikeFromPost: baseProcedure + .input(z.object({postId: z.number()})) + .mutation(async ({input, ctx}): Promise => { + const token = ctx?.userToken + if (!token) return {success: false, error: 'Unauthorized'} + + const user = await loadUser(token) + + return removeLikeFromPostForUser({ + postId: input.postId, + userId: user.id, + }) + }), + + // Additional useful endpoints + hasUserLikedPost: baseProcedure + .input(z.object({postId: z.number()})) + .query(async ({input, ctx}) => { + const token = ctx?.userToken + if (!token) return false + + const user = await loadUser(token) + + return hasUserLikedPost({ + postId: input.postId, + userId: user.id, + }) + }), + + getUserLikedPosts: baseProcedure.query(async ({ctx}) => { + const token = ctx?.userToken + if (!token) return [] + + const user = await loadUser(token) + + return getLikedPostIdsForUser({ + userId: user.id, + }) + }), + + toggleLike: baseProcedure + .input(z.object({postId: z.number()})) + .mutation(async ({input, ctx}): Promise => { + const token = ctx?.userToken + if (!token) return {success: false, error: 'Unauthorized'} + + const user = await loadUser(token) + + const isLiked = await hasUserLikedPost({ + postId: input.postId, + userId: user.id, + }) + + if (isLiked) { + return removeLikeFromPostForUser({ + postId: input.postId, + userId: user.id, + }) + } else { + return addLikeToPostForUser({ + postId: input.postId, + userId: user.id, + }) + } + }), +})