diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 12f244c..e85cab7 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,6 +14,7 @@ import type { FilterApi, FunctionReference, } from "convex/server"; +import type * as follows from "../follows"; import type * as http from "../http"; import type * as likes from "../likes"; import type * as posts from "../posts"; @@ -28,6 +29,7 @@ import type * as users from "../users"; * ``` */ declare const fullApi: ApiFromModules<{ + follows: typeof follows; http: typeof http; likes: typeof likes; posts: typeof posts; diff --git a/convex/follows.ts b/convex/follows.ts new file mode 100644 index 0000000..38895ed --- /dev/null +++ b/convex/follows.ts @@ -0,0 +1,116 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import { userQuery } from './users'; + +export const addFollow = mutation({ + args: { followingId: v.id('users') }, + handler: async (ctx, args) => { + const clerkUser = await ctx.auth.getUserIdentity(); + + if (clerkUser === null) { + return; + } + + const user = await userQuery(ctx, clerkUser.subject); + + if (user === null) { + return; + } + + const existingFollow = await ctx.db + .query('follows') + .withIndex('by_following_follower_id', q => + q.eq('following', args.followingId).eq('follower', user._id) + ) + .unique(); + + if (existingFollow !== null) { + return; + } + + await ctx.db.insert('follows', { + following: args.followingId, + follower: user._id, + }); + }, +}); + +export const removeFollow = mutation({ + args: { followingId: v.id('users') }, + handler: async (ctx, args) => { + const clerkUser = await ctx.auth.getUserIdentity(); + + if (clerkUser === null) { + return; + } + + const user = await userQuery(ctx, clerkUser.subject); + + if (user === null) { + return; + } + + const existingFollow = await ctx.db + .query('follows') + .withIndex('by_following_follower_id', q => + q.eq('following', args.followingId).eq('follower', user._id) + ) + .unique(); + + if (existingFollow === null) { + return; + } + + await ctx.db.delete(existingFollow._id); + }, +}); + +export const getUserFollowerCount = query({ + args: { userId: v.id('users') }, + handler: async (ctx, args) => { + const followers = await ctx.db + .query('follows') + .withIndex('by_following', q => q.eq('following', args.userId)) + .collect(); + + return followers.length; + }, +}); + +export const getUserFollowingCount = query({ + args: { userId: v.id('users') }, + handler: async (ctx, args) => { + const followings = await ctx.db + .query('follows') + .withIndex('by_follower', q => q.eq('follower', args.userId)) + .collect(); + + return followings.length; + }, +}); + +export const getFollowing = query({ + args: { followingId: v.id('users') }, + handler: async (ctx, args) => { + const currentClerkUser = await ctx.auth.getUserIdentity(); + + if (currentClerkUser == null) { + return false; + } + + const user = await userQuery(ctx, currentClerkUser.subject); + + if (user == null) { + return false; + } + + const following = await ctx.db + .query('follows') + .withIndex('by_following_follower_id', q => + q.eq('following', args.followingId).eq('follower', user._id) + ) + .unique(); + + return following !== null; + }, +}); diff --git a/convex/posts.ts b/convex/posts.ts index c443cea..c96b296 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -147,3 +147,59 @@ export const getPostReplyCount = query({ return replies.length; }, }); + +export const getUserPostCount = query({ + args: { userId: v.id('users') }, + handler: async (ctx, args) => { + const posts = await ctx.db + .query('posts') + .withIndex('by_user_id', q => q.eq('userId', args.userId)) + .filter(q => q.eq(q.field('parentPostId'), undefined)) + .collect(); + + return posts.length; + }, +}); + +export const getAllPostsWithUser = query({ + args: { paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + const posts = await ctx.db + .query('posts') + .filter(q => q.eq(q.field('parentPostId'), undefined)) + .order('desc') + .paginate(args.paginationOpts); + + const postsWithUser = await Promise.all( + posts.page.map(async post => { + const user = await ctx.db.get(post.userId); + + if (user === null) { + return { + ...post, + user: { + _id: '' as Id<'users'>, + _creationTime: 0, + clerkUsername: null, + clerkImageUrl: '', + clerkUserId: '', + }, + }; + } + + return { + ...post, + user: { + _id: user._id, + _creationTime: user._creationTime, + clerkUsername: user.clerkUser.username, + clerkImageUrl: user.clerkUser.image_url, + clerkUserId: user.clerkUser.id, + }, + }; + }) + ); + + return { ...posts, page: postsWithUser }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 3e8033d..8d40c56 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -48,6 +48,13 @@ export default defineSchema( }) .index('by_user_post_id', ['userId', 'postId']) .index('by_post_user_id', ['postId', 'userId']), + follows: defineTable({ + following: v.id('users'), // person being followed + follower: v.id('users'), // person following + }) + .index('by_following', ['following']) + .index('by_follower', ['follower']) + .index('by_following_follower_id', ['following', 'follower']), }, { schemaValidation: false } ); diff --git a/convex/users.ts b/convex/users.ts index 0e11f13..b90f9c1 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -93,6 +93,15 @@ export const deleteUser = internalMutation({ .withIndex('by_user_post_id', q => q.eq('userId', userRecord._id)) .collect(); + const userFollowers = await ctx.db + .query('follows') + .withIndex('by_following', q => q.eq('following', userRecord._id)) + .collect(); + const userFollowings = await ctx.db + .query('follows') + .withIndex('by_follower', q => q.eq('follower', userRecord._id)) + .collect(); + await Promise.all( userPosts.map(async post => await ctx.db.delete(post._id)) ); @@ -101,6 +110,14 @@ export const deleteUser = internalMutation({ userLikes.map(async like => await ctx.db.delete(like._id)) ); + await Promise.all( + userFollowers.map(async follower => await ctx.db.delete(follower._id)) + ); + + await Promise.all( + userFollowings.map(async following => await ctx.db.delete(following._id)) + ); + await ctx.db.delete(userRecord._id); }, }); diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index d2f8422..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 1cdf8fa..0f8b653 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,12 @@ +import AllPosts from '@/components/AllPosts'; + export default function Home() { - return
; + return ( +
+
+

Home

+ +
+
+ ); } diff --git a/src/components/AllPosts.tsx b/src/components/AllPosts.tsx new file mode 100644 index 0000000..87ef44a --- /dev/null +++ b/src/components/AllPosts.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { usePaginatedQuery } from 'convex/react'; +import { api } from '../../convex/_generated/api'; +import Loader from './Loader'; +import Post from './Post'; + +export default function AllPosts() { + const PAGE_SIZE = 10; + const pager = usePaginatedQuery( + api.posts.getAllPostsWithUser, + {}, + { initialNumItems: PAGE_SIZE } + ); + + function handleLoadMoreClick() { + pager.loadMore(PAGE_SIZE); + } + + return ( +
+
+ {pager.results.map(post => ( +
+ +
+ ))} +
+ +
+ ); +} diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index 7293e4f..0681dce 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -11,7 +11,7 @@ export default function Loader({ }) { if (isLoading) { return ( -
+
Loading...
@@ -25,7 +25,7 @@ export default function Loader({ return (
+
  • +
    +
    + +
    + setNavOpen(false)} + href='/' + > + Home + +
    +
  • diff --git a/src/components/Post.tsx b/src/components/Post.tsx index fda7603..973008b 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -46,13 +46,15 @@ export default function Post({ return (
    - user profile image + + user profile image +
    diff --git a/src/components/PostActionButtons.tsx b/src/components/PostActionButtons.tsx index 2dfa179..fd57b15 100644 --- a/src/components/PostActionButtons.tsx +++ b/src/components/PostActionButtons.tsx @@ -36,7 +36,7 @@ export default function PostActionButton({ href={{ pathname: `/posts/${postId}`, query: { reply: true } }} > -
    +
    {replyCount}
    @@ -49,8 +49,8 @@ export default function PostActionButton({ isLiked ? 'text-primary-accent' : '' }`} > - {' '} -
    + +
    {likeCount}
    diff --git a/src/components/Reply.tsx b/src/components/Reply.tsx index 0576b24..40ff1b7 100644 --- a/src/components/Reply.tsx +++ b/src/components/Reply.tsx @@ -2,6 +2,7 @@ import { PostWithUserDto } from '@/app/types'; import { useUser } from '@clerk/nextjs'; import { Text } from '@codemirror/state'; import Image from 'next/image'; +import Link from 'next/link'; import PostActionButton from './PostActionButtons'; import PostActionModal from './PostActionModal'; import PostContent from './PostContent'; @@ -17,13 +18,15 @@ export default function Reply({ post }: { post: PostWithUserDto }) { return (
    - user profile image + + user profile image +
    diff --git a/src/components/UserButton.tsx b/src/components/UserButton.tsx index 9323c32..b6dd0e7 100644 --- a/src/components/UserButton.tsx +++ b/src/components/UserButton.tsx @@ -1,8 +1,9 @@ 'use client'; import { useMountedEffect } from '@/hooks'; import { UserButton as ClerkUserButton } from '@clerk/nextjs'; +import { ReactNode } from 'react'; -export default function UserButton() { +export default function UserButton({ children }: { children?: ReactNode }) { const { mounted } = useMountedEffect(); if (mounted === false) { diff --git a/src/components/UserPosts.tsx b/src/components/UserPosts.tsx index 01bb3fe..ef35256 100644 --- a/src/components/UserPosts.tsx +++ b/src/components/UserPosts.tsx @@ -42,12 +42,12 @@ export default function UserPosts({ user }: { user: UserDto }) { } return ( -
    +
    {postsWithUser.map(post => (
    diff --git a/src/components/UserProfile.tsx b/src/components/UserProfile.tsx index c1f2226..21e617e 100644 --- a/src/components/UserProfile.tsx +++ b/src/components/UserProfile.tsx @@ -1,15 +1,53 @@ +'use client'; + import { UserDto } from '@/app/types'; +import { useUser } from '@clerk/nextjs'; +import { useMutation, useQuery } from 'convex/react'; import Image from 'next/image'; import { AiFillCalendar } from 'react-icons/ai'; +import { api } from '../../convex/_generated/api'; export default function UserProfile({ user }: { user: UserDto }) { + const { isLoaded, isSignedIn, user: currentUser } = useUser(); + const addFollow = useMutation(api.follows.addFollow); + const removeFollow = useMutation(api.follows.removeFollow); + + const followerCount = useQuery(api.follows.getUserFollowerCount, { + userId: user._id, + }); + + const followingCount = useQuery(api.follows.getUserFollowingCount, { + userId: user._id, + }); + + const isFollowing = useQuery(api.follows.getFollowing, { + followingId: user._id, + }); + + const postCount = useQuery(api.posts.getUserPostCount, { userId: user._id }); const username = user.clerkUsername ?? user._id; const userCreatedDate = new Date(user._creationTime); + const monthJoined = userCreatedDate.toLocaleString(undefined, { month: 'short', }); + const yearJoined = userCreatedDate.getFullYear(); + const countDataLoaded = + postCount !== undefined && + followerCount !== undefined && + followingCount !== undefined; + + async function handleFollowButtonClick() { + if (isFollowing) { + await removeFollow({ followingId: user._id }); + return; + } + + await addFollow({ followingId: user._id }); + } + return (
    @@ -23,21 +61,59 @@ export default function UserProfile({ user }: { user: UserDto }) { className='rounded-full object-cover border-4 border-primary-accent' />
    -
    -

    - {username} -

    -
    - - {`Joined ${monthJoined} ${yearJoined}`} +
    +
    +

    + {username} +

    +
    + + {`Joined ${monthJoined} ${yearJoined}`} +
    +
    +
    + {isLoaded && isSignedIn && currentUser.id !== user.clerkUserId ? ( + + ) : null}
    -
    +
    +
    + {countDataLoaded ? ( + <> +
    + {postCount}{' '} + posts +
    +
    + + {followerCount} + {' '} + followers +
    +
    + + {followingCount} + {' '} + following +
    + + ) : null} +
    +
    ); }