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,
+ })
+ }
+ }),
+})