-
Notifications
You must be signed in to change notification settings - Fork 193
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
311 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}) | ||
} | ||
}), | ||
}) |