Skip to content

Commit

Permalink
feat: yolo in likes
Browse files Browse the repository at this point in the history
  • Loading branch information
joelhooks committed Oct 31, 2024
1 parent 4ad36d9 commit dc1ec49
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 8 deletions.
87 changes: 87 additions & 0 deletions src/components/LikeButton.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<button
onClick={() => toggleLike({postId})}
disabled={isLoading}
className={cn(
'flex items-center gap-1.5 text-sm transition-colors',
mounted && optimisticLiked
? 'text-blue-500'
: 'text-gray-500 hover:text-blue-500',
isLoading && 'opacity-50',
className,
)}
aria-label={optimisticLiked ? 'Unlike post' : 'Like post'}
>
<ThumbsUp
className={cn('h-5 w-5', isLoading && 'animate-pulse')}
fill={mounted && optimisticLiked ? 'currentColor' : 'none'}
/>
{optimisticCount !== null ? (
<span>{optimisticCount}</span>
) : (
<span className="w-3 h-3 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
)}
</button>
)
}
111 changes: 111 additions & 0 deletions src/lib/likes.ts
Original file line number Diff line number Diff line change
@@ -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<LikeResponse> {
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<LikeResponse> {
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)
}
20 changes: 12 additions & 8 deletions src/pages/[post].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -326,15 +327,18 @@ export default function PostPage({
</p>
</div>
)}
<header className="pb-6 pt-7 sm:pb-18 sm:pt-16 space-y-4 ">
<h1 className="max-w-screen-md font-extrabold sm:text-4xl text-2xl leading-tighter w-fit pb-6">
<header className="pb-6 pt-7 sm:pb-18 sm:pt-16 space-y-4">
<h1 className="max-w-screen-md font-extrabold sm:text-4xl text-2xl leading-tighter">
{post.fields.title}
</h1>
<div className="flex items-center justify-between gap-2">
<div>
<InstructorProfile instructor={instructor} />
<div className="flex items-center gap-2">
<div className="ml-auto">
<LikeButton postId={post.fields.eggheadLessonId} />
</div>
<div>
</div>
<div className="flex items-center gap-2">
<InstructorProfile instructor={instructor} />
<div className="ml-auto">
<TagList tags={tags} resourceSlug={post.fields.slug} />
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/server/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!'),
Expand All @@ -23,6 +24,7 @@ export const appRouter = router({
customerIO: customerIORouter,
tips: tipsRouter,
lesson: lessonRouter,
likes: likesRouter,
})

export type AppRouter = typeof appRouter
99 changes: 99 additions & 0 deletions src/server/routers/likes.ts
Original file line number Diff line number Diff line change
@@ -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<LikeResponse> => {
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<LikeResponse> => {
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<LikeResponse> => {
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,
})
}
}),
})

0 comments on commit dc1ec49

Please sign in to comment.