diff --git a/convex/posts.ts b/convex/posts.ts index 174ac24..fb87802 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -1,6 +1,7 @@ import { paginationOptsValidator } from 'convex/server'; import { v } from 'convex/values'; import { PostWithUserDto } from '../src/app/types'; +import { Id } from './_generated/dataModel'; import { mutation, query } from './_generated/server'; import { userQuery } from './users'; @@ -86,7 +87,51 @@ export const getPostById = query({ _creationTime: user._creationTime, clerkUsername: user.clerkUser.username, clerkImageUrl: user.clerkUser.image_url, + clerkUserId: user.clerkUser.id, }, }; }, }); + +export const getRepliesByParentId = query({ + args: { id: v.id('posts'), paginationOpts: paginationOptsValidator }, + handler: async (ctx, args) => { + const replies = await ctx.db + .query('posts') + .withIndex('by_parent_id', q => q.eq('parentPostId', args.id)) + .order('desc') + .paginate(args.paginationOpts); + + const repliesWithUserData = await Promise.all( + replies.page.map(async reply => { + const user = await ctx.db.get(reply.userId); + + if (user === null) { + return { + ...reply, + user: { + _id: '' as Id<'users'>, + _creationTime: 0, + clerkUsername: null, + clerkImageUrl: '', + clerkUserId: '', + }, + }; + } + + return { + ...reply, + user: { + _id: user._id, + _creationTime: user._creationTime, + clerkUsername: user.clerkUser.username, + clerkImageUrl: user.clerkUser.image_url, + clerkUserId: user.clerkUser.id, + }, + }; + }) + ); + + return { ...replies, page: repliesWithUserData }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 7fa6d9c..4431f79 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -39,7 +39,9 @@ export default defineSchema( parentPostId: v.optional(v.id('posts')), userId: v.id('users'), content: v.array(v.string()), - }).index('by_user_id', ['userId']), + }) + .index('by_user_id', ['userId']) + .index('by_parent_id', ['parentPostId']), }, { schemaValidation: false } ); diff --git a/convex/users.ts b/convex/users.ts index 137fd71..bccc86d 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -28,6 +28,7 @@ export const getUserByClerkId = query({ _creationTime: user._creationTime, clerkUsername: user.clerkUser.username, clerkImageUrl: user.clerkUser.image_url, + clerkUserId: user.clerkUser.id, }; }, }); @@ -46,6 +47,7 @@ export const getUserById = query({ _creationTime: user._creationTime, clerkUsername: user.clerkUser.username, clerkImageUrl: user.clerkUser.image_url, + clerkUserId: user.clerkUser.id, }; }, }); diff --git a/src/app/posts/[id]/edit/page.tsx b/src/app/posts/[id]/edit/page.tsx index 8ffdbb8..214f965 100644 --- a/src/app/posts/[id]/edit/page.tsx +++ b/src/app/posts/[id]/edit/page.tsx @@ -45,8 +45,7 @@ export default function EditPostPage({ params }: { params: { id: string } }) { case 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER': throw Error('Unable to update post'); default: - 'use server'; - router.push('/'); + router.push(`/posts/${post?._id}`); return; } } diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index 4e618e4..b5b12c0 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -2,11 +2,13 @@ import Editor, { SubmitActionParams } from '@/components/Editor'; import Post from '@/components/Post'; +import PostReplies from '@/components/PostReplies'; import SpinningLoader from '@/components/SpinningLoader'; import { useRouter } from '@/hooks'; import { useUser } from '@clerk/nextjs'; import { useMutation, useQuery } from 'convex/react'; import Image from 'next/image'; +import { useEffect, useRef } from 'react'; import { api } from '../../../../convex/_generated/api'; import { Id } from '../../../../convex/_generated/dataModel'; @@ -15,14 +17,22 @@ export default function PostPage({ searchParams, }: { params: { id: string }; - searchParams: { reply?: boolean }; + searchParams: { [key: string]: string | string[] | undefined }; }) { + const replyRef = useRef(null); const user = useUser(); const router = useRouter(); const post = useQuery(api.posts.getPostById, { id: params.id as Id<'posts'>, }); const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost); + const isReply = searchParams.reply === 'true'; + + useEffect(() => { + if (replyRef.current !== null && isReply) { + replyRef.current.scrollIntoView(); + } + }, [isReply, post]); async function submitAction({ clerkUserId, @@ -69,6 +79,9 @@ export default function PostPage({ showReply={false} /> +
+ +
- {/* TODO: Display replies as paged query. Should show latest replies with option to load more */} -
+
diff --git a/src/app/types/index.ts b/src/app/types/index.ts index 2ddc91d..7fc5b34 100644 --- a/src/app/types/index.ts +++ b/src/app/types/index.ts @@ -5,6 +5,7 @@ export type UserDto = { _creationTime: number; clerkUsername: string | null; clerkImageUrl: string; + clerkUserId: string; }; export type PostWithUserDto = Doc<'posts'> & { user: UserDto }; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 9a3878f..47cdb8a 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -8,7 +8,7 @@ import { languages } from '@codemirror/language-data'; import { Compartment, EditorState, Text } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; import { basicSetup } from 'codemirror'; -import { FormEvent, useState } from 'react'; +import { FormEvent, useEffect, useState } from 'react'; import { Doc, Id } from '../../convex/_generated/dataModel'; import PostContent from './PostContent'; @@ -24,6 +24,7 @@ export default function Editor({ parentPostId, post, submitAction, + autofocus = true, }: { clerkUserId: string; parentPostId?: Id<'posts'>; @@ -34,6 +35,7 @@ export default function Editor({ post, currentDoc, }: SubmitActionParams) => Promise; + autofocus?: boolean; }) { const editorTheme = new Compartment(); const [currentDoc, setCurrentDoc] = useState(post?.content ?? ['']); @@ -58,6 +60,12 @@ export default function Editor({ extensions, }); + useEffect(() => { + if (editorView !== null && autofocus) { + editorView.focus(); + } + }, [autofocus, editorView]); + async function handleSubmit(e: FormEvent) { e.preventDefault(); diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 0000000..7293e4f --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,43 @@ +import SpinningLoader from './SpinningLoader'; + +export default function Loader({ + isLoading, + status, + loadButtonClickHandler, +}: { + isLoading: boolean; + status: string; + loadButtonClickHandler: () => void; +}) { + if (isLoading) { + return ( +
+ + Loading... +
+ ); + } + + if (status !== 'CanLoadMore') { + return; + } + + return ( +
+ +
+ ); +} diff --git a/src/components/Post.tsx b/src/components/Post.tsx index d4bbad4..fda7603 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -1,15 +1,15 @@ 'use client'; import { PostWithUserDto } from '@/app/types'; +import { useUser } from '@clerk/nextjs'; import { Text } from '@codemirror/state'; import { useMutation } from 'convex/react'; import Image from 'next/image'; import Link from 'next/link'; -import { useEffect, useRef, useState } from 'react'; -import { BiDotsHorizontalRounded, BiSolidLike } from 'react-icons/bi'; -import { BsFillReplyFill } from 'react-icons/bs'; import { GoDotFill } from 'react-icons/go'; import { api } from '../../convex/_generated/api'; +import PostActionButton from './PostActionButtons'; +import PostActionModal from './PostActionModal'; import PostContent from './PostContent'; export default function Post({ @@ -21,9 +21,8 @@ export default function Post({ limit?: boolean; showReply?: boolean; }) { + const { isLoaded, user, isSignedIn } = useUser(); const deletePostById = useMutation(api.posts.deletePostById); - const modalRef = useRef(null); - const [modalOpen, setModalOpen] = useState(false); const username = post.user.clerkUsername ?? post.user._id; const createdPostDate = new Date(post._creationTime); const postYear = createdPostDate.getFullYear(); @@ -34,25 +33,7 @@ export default function Post({ const content = limit ? post.content.slice(0, 10) : post.content; const postLink = `/posts/${post._id}`; - // TODO: Add reply and like buttons - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if ( - modalOpen && - modalRef.current && - modalRef.current.contains(e.target as Node) === false - ) { - setModalOpen(false); - } - } - - document.addEventListener('click', handleClickOutside); - return () => document.removeEventListener('click', handleClickOutside); - }, [modalOpen]); - async function handleDeleteClick() { - setModalOpen(false); const result = confirm('Are you sure you want to delete this post?'); if (result === false) { @@ -89,36 +70,9 @@ export default function Post({
-
- -
-
    -
  • - setModalOpen(false)} - href={`${postLink}/edit`} - > - Edit post - -
  • -
  • - -
  • -
-
-
+ {isLoaded && isSignedIn && user.id === post.user.clerkUserId ? ( + + ) : null}
) : null}
-
- {showReply ? ( - - - - ) : null} - -
+ ); diff --git a/src/components/PostActionButtons.tsx b/src/components/PostActionButtons.tsx new file mode 100644 index 0000000..4df11e6 --- /dev/null +++ b/src/components/PostActionButtons.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import { BiSolidLike } from 'react-icons/bi'; +import { BsFillReplyFill } from 'react-icons/bs'; +import { Id } from '../../convex/_generated/dataModel'; + +export default function PostActionButton({ + postId, + showReply, +}: { + postId: Id<'posts'>; + showReply: boolean; +}) { + return ( +
+ {showReply ? ( + + + + ) : null} + +
+ ); +} diff --git a/src/components/PostActionModal.tsx b/src/components/PostActionModal.tsx new file mode 100644 index 0000000..5df965f --- /dev/null +++ b/src/components/PostActionModal.tsx @@ -0,0 +1,70 @@ +import Link from 'next/link'; +import { useEffect, useRef, useState } from 'react'; +import { BiDotsHorizontalRounded } from 'react-icons/bi'; +import { Id } from '../../convex/_generated/dataModel'; +import { useMutation } from 'convex/react'; +import { api } from '../../convex/_generated/api'; + +export default function PostActionModal({ postId }: { postId: Id<'posts'> }) { + const modalRef = useRef(null); + const [modalOpen, setModalOpen] = useState(false); + const deletePostById = useMutation(api.posts.deletePostById); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + modalOpen && + modalRef.current && + modalRef.current.contains(e.target as Node) === false + ) { + setModalOpen(false); + } + } + + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, [modalOpen]); + + async function handleDeleteButtonClick() { + const result = confirm('Are you sure you want to delete this post?'); + + if (result === false) { + return; + } + + await deletePostById({ id: postId }); + } + + return ( +
+ +
+
    +
  • + setModalOpen(false)} + href={`/posts/${postId}/edit`} + > + Edit post + +
  • +
  • + +
  • +
+
+
+ ); +} diff --git a/src/components/PostReplies.tsx b/src/components/PostReplies.tsx new file mode 100644 index 0000000..68d4edb --- /dev/null +++ b/src/components/PostReplies.tsx @@ -0,0 +1,42 @@ +import { usePaginatedQuery } from 'convex/react'; +import { api } from '../../convex/_generated/api'; +import { Id } from '../../convex/_generated/dataModel'; +import Loader from './Loader'; +import Reply from './Reply'; + +export default function PostReplies({ parentId }: { parentId: Id<'posts'> }) { + const PAGE_SIZE = 10; + const pager = usePaginatedQuery( + api.posts.getRepliesByParentId, + { id: parentId }, + { initialNumItems: PAGE_SIZE } + ); + + function handleLoadMoreClick() { + pager.loadMore(PAGE_SIZE); + } + + if (pager.isLoading === false && pager.results.length === 0) { + return; + } + + return ( +
+
+ {pager.results.map(reply => ( +
+ +
+ ))} +
+ +
+ ); +} diff --git a/src/components/Reply.tsx b/src/components/Reply.tsx new file mode 100644 index 0000000..5977c1a --- /dev/null +++ b/src/components/Reply.tsx @@ -0,0 +1,54 @@ +import { PostWithUserDto } from '@/app/types'; +import { useUser } from '@clerk/nextjs'; +import { Text } from '@codemirror/state'; +import Image from 'next/image'; +import PostActionButton from './PostActionButtons'; +import PostActionModal from './PostActionModal'; +import PostContent from './PostContent'; + +export default function Reply({ post }: { post: PostWithUserDto }) { + const { user, isSignedIn, isLoaded } = useUser(); + const username = post.user.clerkUsername ?? post.user._id; + const created = new Date(post._creationTime); + const month = created.toLocaleString(undefined, { month: 'short' }); + const day = created.getDate(); + const year = created.getFullYear(); + + return ( +
+
+ user profile image +
+
+
+
+
+ {`${username} `} + {`replied on ${month} ${day}, ${year}`} +
+ {isLoaded && isSignedIn && user.id === post.user.clerkUserId ? ( + + ) : null} +
+
+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/UserPosts.tsx b/src/components/UserPosts.tsx index 58e41c5..01bb3fe 100644 --- a/src/components/UserPosts.tsx +++ b/src/components/UserPosts.tsx @@ -4,11 +4,11 @@ import { UserDto } from '@/app/types'; import { usePaginatedQuery } from 'convex/react'; import Link from 'next/link'; import { api } from '../../convex/_generated/api'; +import Loader from './Loader'; import Post from './Post'; -import SpinningLoader from './SpinningLoader'; export default function UserPosts({ user }: { user: UserDto }) { - const PAGE_SIZE = 2; + const PAGE_SIZE = 10; const pager = usePaginatedQuery( api.posts.getUsersPostById, { @@ -19,7 +19,11 @@ export default function UserPosts({ user }: { user: UserDto }) { const postsWithUser = pager.results.map(post => ({ ...post, user })); - if (pager.isLoading === false && postsWithUser.length == 0) { + function handleLoadMoreClick() { + pager.loadMore(PAGE_SIZE); + } + + if (pager.isLoading === false && postsWithUser.length === 0) { return (
@@ -38,7 +42,7 @@ export default function UserPosts({ user }: { user: UserDto }) { } return ( -
+
{postsWithUser.map(post => (
))}
- {pager.isLoading ? ( -
- - Loading... -
- ) : pager.status === 'CanLoadMore' ? ( -
- -
- ) : null} +
); }