From b441b781a02cf4594cf3ea314ca8fe023fd3e2cd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:56:07 -0500 Subject: [PATCH 1/5] feat: add reply editor to post page --- src/app/page.tsx | 2 +- src/app/posts/[id]/page.tsx | 45 +++++++++++++++++++++++++++++++------ src/components/Editor.tsx | 5 ++++- src/components/Post.tsx | 21 +++++++++++++---- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 7bff2ad..1cdf8fa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,3 @@ export default function Home() { - return
; + return
; } diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index 1a0b329..f1bec51 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -1,12 +1,22 @@ 'use client'; +import Editor from '@/components/Editor'; import Post from '@/components/Post'; import SpinningLoader from '@/components/SpinningLoader'; +import { useUser } from '@clerk/nextjs'; import { useQuery } from 'convex/react'; +import Image from 'next/image'; import { api } from '../../../../convex/_generated/api'; import { Id } from '../../../../convex/_generated/dataModel'; -export default function PostPage({ params }: { params: { id: string } }) { +export default function PostPage({ + params, + searchParams, +}: { + params: { id: string }; + searchParams: { reply?: boolean }; +}) { + const user = useUser(); const post = useQuery(api.posts.getPostById, { id: params.id as Id<'posts'>, }); @@ -18,14 +28,35 @@ export default function PostPage({ params }: { params: { id: string } }) { return (
-
+
{post === undefined ? ( - +
+ Loading post... +
) : ( - + <> +
+ +
+
+ {user.isSignedIn !== true ? null : ( +
+ user profile image +
+ )} + +
+ )}
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 562cf3e..0073fe5 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -36,7 +36,10 @@ export default function Editor({ post }: { post?: Doc<'posts'> }) { ]; const [mode, setMode] = useState<'write' | 'preview'>('write'); - const { editorRef } = useCodeMirror({ doc: currentDoc, extensions }); + const { editorRef } = useCodeMirror({ + doc: currentDoc, + extensions, + }); async function handleSubmit(e: FormEvent) { e.preventDefault(); diff --git a/src/components/Post.tsx b/src/components/Post.tsx index 6129124..49f0f14 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -6,7 +6,8 @@ import { useMutation } from 'convex/react'; import Image from 'next/image'; import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; -import { BiDotsHorizontalRounded } from 'react-icons/bi'; +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 PostContent from './PostContent'; @@ -14,9 +15,11 @@ import PostContent from './PostContent'; export default function Post({ post, limit = true, + showReply = true, }: { post: PostWithUserDto; limit?: boolean; + showReply?: boolean; }) { const deletePostById = useMutation(api.posts.deletePostById); const modalRef = useRef(null); @@ -29,6 +32,7 @@ export default function Post({ }); const dayOfMonth = createdPostDate.getDate(); const content = limit ? post.content.slice(0, 10) : post.content; + const postLink = `/posts/${post._id}`; // TODO: Add reply and like buttons @@ -92,7 +96,7 @@ export default function Post({
  • setModalOpen(false)} - href={`/posts/${post._id}/edit`} + href={`${postLink}/edit`} > Edit post @@ -118,13 +122,22 @@ export default function Post({ {limit && post.content.length > 10 ? ( Show more ) : null} -
    +
    + {showReply ? ( + + + + ) : null} + +
    ); From 272a74cf5d3ff6b146b36b64229adbe17ecf5e96 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 12 Sep 2023 23:12:12 -0500 Subject: [PATCH 2/5] style: figure out responsive issues --- src/app/account/[[...account]]/page.tsx | 5 +- src/app/layout.tsx | 4 +- src/app/login/[[...login]]/page.tsx | 4 +- src/app/posts/[id]/page.tsx | 8 +- src/app/signup/[[...signup]]/page.tsx | 4 +- src/components/Editor.tsx | 11 +- src/components/Navbar.tsx | 200 +++++++++++++++++------- src/components/Post.tsx | 25 +-- src/components/ThemeButton.tsx | 6 +- src/components/UserProfile.tsx | 13 +- 10 files changed, 191 insertions(+), 89 deletions(-) diff --git a/src/app/account/[[...account]]/page.tsx b/src/app/account/[[...account]]/page.tsx index 1cdc062..a89865d 100644 --- a/src/app/account/[[...account]]/page.tsx +++ b/src/app/account/[[...account]]/page.tsx @@ -8,8 +8,9 @@ export default function AccountPage() { -
    +
    {children}
    diff --git a/src/app/login/[[...login]]/page.tsx b/src/app/login/[[...login]]/page.tsx index 44caa24..4f7c6e4 100644 --- a/src/app/login/[[...login]]/page.tsx +++ b/src/app/login/[[...login]]/page.tsx @@ -4,11 +4,11 @@ import { SignIn } from '@clerk/nextjs'; export default function LoginPage() { return ( -
    +
    -
    +
    {post === undefined ? (
    Loading post...
    ) : ( <> -
    +
    )} - +
    + +
    )} diff --git a/src/app/signup/[[...signup]]/page.tsx b/src/app/signup/[[...signup]]/page.tsx index 5b8ef4b..c2d2ef4 100644 --- a/src/app/signup/[[...signup]]/page.tsx +++ b/src/app/signup/[[...signup]]/page.tsx @@ -4,11 +4,11 @@ import { SignUp } from '@clerk/nextjs'; export default function SignUpPage() { return ( -
    +
    }) { +export default function Editor({ + parentPostId, + post, +}: { + parentPostId?: Id<'posts'>; + post?: Doc<'posts'>; +}) { const { user, isSignedIn } = useUser(); const editorTheme = new Compartment(); const [currentDoc, setCurrentDoc] = useState(post?.content ?? ['']); @@ -52,6 +58,7 @@ export default function Editor({ post }: { post?: Doc<'posts'> }) { setCreatingOrUpdating(true); const result = await createOrUpdatePost({ + parentPostId: parentPostId, id: post?._id, clerkUserId: user.id, content: currentDoc, diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ca8cd82..5d15516 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,71 +1,151 @@ -import { SignedIn, SignedOut, currentUser } from '@clerk/nextjs'; +'use client'; + +import { SignedIn, SignedOut, useUser } from '@clerk/nextjs'; import Link from 'next/link'; -import { BiPlus, BiSolidMessageSquare } from 'react-icons/bi'; +import { useEffect, useState } from 'react'; +import { BiLogIn, BiPlus } from 'react-icons/bi'; +import { BsPersonPlusFill } from 'react-icons/bs'; +import { ImProfile } from 'react-icons/im'; +import { + RiMenuFoldLine, + RiMenuUnfoldLine, + RiUserFollowFill, +} from 'react-icons/ri'; import ThemeButton from './ThemeButton'; import UserButton from './UserButton'; -export default async function Navbar() { - const user = await currentUser(); +export default function Navbar() { + const { user } = useUser(); + const [navOpen, setNavOpen] = useState(false); + + useEffect(() => { + function handler() { + if (navOpen) { + setNavOpen(false); + } + } + + window.addEventListener('resize', handler); + + return () => window.removeEventListener('resize', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( -
    ); } diff --git a/src/components/Post.tsx b/src/components/Post.tsx index 49f0f14..d4bbad4 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -63,8 +63,8 @@ export default function Post({ } return ( -
    -
    +
    +
    user profile image
    -
    +
    -
    -
    {username}
    - -
    - {`${postMonth} ${dayOfMonth}, ${postYear}`} +
    +
    + {username} +
    +
    + +
    + {`${postMonth} ${dayOfMonth}, ${postYear}`} +
    @@ -114,7 +121,7 @@ export default function Post({
    diff --git a/src/components/ThemeButton.tsx b/src/components/ThemeButton.tsx index 76b6b8c..2c0f187 100644 --- a/src/components/ThemeButton.tsx +++ b/src/components/ThemeButton.tsx @@ -17,11 +17,11 @@ export default function ThemeButton() { onClick={() => setTheme(isDark ? 'light' : 'dark')} > {mounted === false ? ( - + ) : isDark ? ( - + ) : ( - + )} ); diff --git a/src/components/UserProfile.tsx b/src/components/UserProfile.tsx index 23413fe..c1f2226 100644 --- a/src/components/UserProfile.tsx +++ b/src/components/UserProfile.tsx @@ -12,9 +12,9 @@ export default function UserProfile({ user }: { user: UserDto }) { return (
    -
    +
    -
    +
    user profile image
    -
    -

    {username}

    +
    +

    + {username} +

    {`Joined ${monthJoined} ${yearJoined}`} From aa85477fbe8f509b29d5cbd8b60701f25f71278d Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:19:30 -0500 Subject: [PATCH 3/5] refactor: allow providing submit action to editor component to allow posting replys and not redirecting on post. fix: also clear the editor after posting --- convex/posts.ts | 1 + src/app/posts/[id]/edit/page.tsx | 66 ++++++++++++++++++++++++----- src/app/posts/[id]/page.tsx | 72 +++++++++++++++++++++++--------- src/app/posts/add/page.tsx | 57 +++++++++++++++++++++++-- src/components/Editor.tsx | 65 ++++++++++++++-------------- 5 files changed, 196 insertions(+), 65 deletions(-) diff --git a/convex/posts.ts b/convex/posts.ts index e4b6368..174ac24 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -46,6 +46,7 @@ export const getUsersPostById = query({ return await ctx.db .query('posts') .withIndex('by_user_id', q => q.eq('userId', args.userId)) + .filter(q => q.eq(q.field('parentPostId'), undefined)) .order('desc') .paginate(args.paginationOpts); }, diff --git a/src/app/posts/[id]/edit/page.tsx b/src/app/posts/[id]/edit/page.tsx index 899f183..8ffdbb8 100644 --- a/src/app/posts/[id]/edit/page.tsx +++ b/src/app/posts/[id]/edit/page.tsx @@ -1,26 +1,70 @@ -import Editor from '@/components/Editor'; -import { getConvexClient } from '@/lib/convex'; +'use client'; + +import Editor, { SubmitActionParams } from '@/components/Editor'; +import SpinningLoader from '@/components/SpinningLoader'; +import { useRouter } from '@/hooks'; +import { useUser } from '@clerk/nextjs'; +import { useMutation, useQuery } from 'convex/react'; import { api } from '../../../../../convex/_generated/api'; import { Id } from '../../../../../convex/_generated/dataModel'; -export default async function EditPostPage({ - params, -}: { - params: { id: string }; -}) { - const client = await getConvexClient(); - const post = await client.query(api.posts.getPostById, { +export default function EditPostPage({ params }: { params: { id: string } }) { + const user = useUser(); + const post = useQuery(api.posts.getPostById, { id: params.id as Id<'posts'>, }); + const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost); + const router = useRouter(); + + if (user.isSignedIn === false) { + router.push('/login'); + return; + } if (post === 'POST_NOT_FOUND' || post === 'USER_FOR_POST_NOT_FOUND') { // TODO: Return actual not found component return

    Not Found

    ; } + async function submitAction({ + clerkUserId, + parentPostId, + post, + currentDoc, + }: SubmitActionParams) { + const result = await createOrUpdatePost({ + parentPostId: parentPostId, + id: post?._id, + clerkUserId: clerkUserId, + content: currentDoc, + }); + + switch (result) { + case 'USER_NOT_FOUND': + case 'USER_NOT_AUTHORIZED': + case 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER': + throw Error('Unable to update post'); + default: + 'use server'; + router.push('/'); + return; + } + } + return ( -
    - +
    + {user.isLoaded && post !== undefined ? ( + + ) : ( +
    + + Loading... +
    + )}
    ); } diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index 0da092b..8a56fca 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -1,10 +1,11 @@ 'use client'; -import Editor from '@/components/Editor'; +import Editor, { SubmitActionParams } from '@/components/Editor'; import Post from '@/components/Post'; import SpinningLoader from '@/components/SpinningLoader'; +import { useRouter } from '@/hooks'; import { useUser } from '@clerk/nextjs'; -import { useQuery } from 'convex/react'; +import { useMutation, useQuery } from 'convex/react'; import Image from 'next/image'; import { api } from '../../../../convex/_generated/api'; import { Id } from '../../../../convex/_generated/dataModel'; @@ -17,9 +18,39 @@ export default function PostPage({ searchParams: { reply?: boolean }; }) { const user = useUser(); + const router = useRouter(); const post = useQuery(api.posts.getPostById, { id: params.id as Id<'posts'>, }); + const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost); + + async function submitAction({ + clerkUserId, + parentPostId, + post, + currentDoc, + }: SubmitActionParams) { + const result = await createOrUpdatePost({ + parentPostId: parentPostId, + id: post?._id, + clerkUserId: clerkUserId, + content: currentDoc, + }); + + switch (result) { + case 'USER_NOT_FOUND': + case 'USER_NOT_AUTHORIZED': + case 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER': + throw Error('Unable to create post'); + default: + return; + } + } + + if (user.isSignedIn === false) { + router.push('/login'); + return; + } if (post === 'POST_NOT_FOUND' || post === 'USER_FOR_POST_NOT_FOUND') { // TODO: Return actual not found component @@ -29,11 +60,7 @@ export default function PostPage({ return (
    - {post === undefined ? ( -
    - Loading post... -
    - ) : ( + {post !== undefined && user.isLoaded ? ( <>
    - {user.isSignedIn !== true ? null : ( -
    - user profile image -
    - )} +
    + user profile image +
    +
    - +
    + ) : ( +
    + Loading post... +
    )}
    diff --git a/src/app/posts/add/page.tsx b/src/app/posts/add/page.tsx index 41d4eb8..636f799 100644 --- a/src/app/posts/add/page.tsx +++ b/src/app/posts/add/page.tsx @@ -1,9 +1,60 @@ -import Editor from '@/components/Editor'; +'use client'; + +import Editor, { SubmitActionParams } from '@/components/Editor'; +import SpinningLoader from '@/components/SpinningLoader'; +import { useRouter } from '@/hooks'; +import { useUser } from '@clerk/nextjs'; +import { useMutation } from 'convex/react'; +import { api } from '../../../../convex/_generated/api'; export default function AddPost() { + const user = useUser(); + const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost); + const router = useRouter(); + + async function submitAction({ + clerkUserId, + parentPostId, + post, + currentDoc, + }: SubmitActionParams) { + const result = await createOrUpdatePost({ + parentPostId: parentPostId, + id: post?._id, + clerkUserId: clerkUserId, + content: currentDoc, + }); + + switch (result) { + case 'USER_NOT_FOUND': + case 'USER_NOT_AUTHORIZED': + case 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER': + throw Error('Unable to create post'); + default: + 'use server'; + router.push('/'); + return; + } + } + + if (user.isSignedIn === false) { + router.push('/login'); + return; + } + return ( -
    - +
    + {user.isLoaded ? ( + + ) : ( +
    + + Loading... +
    + )}
    ); } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 40ed0be..9a3878f 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -2,30 +2,41 @@ import { useCodeMirror, useRouter } from '@/hooks'; import { basicDark } from '@/lib/codemirror'; -import { useUser } from '@clerk/nextjs'; import { indentWithTab } from '@codemirror/commands'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { Compartment, EditorState, Text } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; import { basicSetup } from 'codemirror'; -import { useMutation } from 'convex/react'; import { FormEvent, useState } from 'react'; -import { api } from '../../convex/_generated/api'; import { Doc, Id } from '../../convex/_generated/dataModel'; import PostContent from './PostContent'; -export default function Editor({ - parentPostId, - post, -}: { +export type SubmitActionParams = { + clerkUserId: string; parentPostId?: Id<'posts'>; post?: Doc<'posts'>; + currentDoc: string[]; +}; + +export default function Editor({ + clerkUserId, + parentPostId, + post, + submitAction, +}: { + clerkUserId: string; + parentPostId?: Id<'posts'>; + post?: Doc<'posts'>; + submitAction: ({ + clerkUserId, + parentPostId, + post, + currentDoc, + }: SubmitActionParams) => Promise; }) { - const { user, isSignedIn } = useUser(); const editorTheme = new Compartment(); const [currentDoc, setCurrentDoc] = useState(post?.content ?? ['']); - const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost); const router = useRouter(); const [creatingOrUpdating, setCreatingOrUpdating] = useState(false); @@ -42,48 +53,38 @@ export default function Editor({ ]; const [mode, setMode] = useState<'write' | 'preview'>('write'); - const { editorRef } = useCodeMirror({ + const { editorRef, editorView } = useCodeMirror({ doc: currentDoc, extensions, }); async function handleSubmit(e: FormEvent) { e.preventDefault(); + try { - if (isSignedIn !== true) { - router.push('/login'); - return; - } - setCreatingOrUpdating(true); - - const result = await createOrUpdatePost({ - parentPostId: parentPostId, - id: post?._id, - clerkUserId: user.id, - content: currentDoc, + await submitAction({ + clerkUserId, + parentPostId, + post, + currentDoc, }); - - switch (result) { - case 'USER_NOT_FOUND': - case 'USER_NOT_AUTHORIZED': - case 'CANNOT_POST_ON_BEHALF_OF_ANOTHER_USER': - throw Error('Unable to create post'); - default: - router.push('/'); - return; - } } catch (error) { console.log(error); } finally { + console.log('here'); setCreatingOrUpdating(false); + setCurrentDoc(['']); + editorView?.dispatch({ + changes: { from: 0, to: editorView.state.doc.length, insert: '' }, + }); } } return (
    From 0dc2934e056b91d526e6f2a292c5bd78746c9a66 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:20:58 -0500 Subject: [PATCH 4/5] chore: add todo --- src/app/posts/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index 8a56fca..4e618e4 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -79,7 +79,7 @@ export default function PostPage({ className='rounded-full object-cover border-4 border-primary-accent' />
    - + {/* TODO: Display replies as paged query. Should show latest replies with option to load more */}
    Date: Thu, 14 Sep 2023 21:47:40 -0500 Subject: [PATCH 5/5] feat: support replies --- convex/posts.ts | 45 +++++++++++++++++ convex/schema.ts | 4 +- convex/users.ts | 2 + src/app/posts/[id]/edit/page.tsx | 3 +- src/app/posts/[id]/page.tsx | 22 +++++++-- src/app/types/index.ts | 1 + src/components/Editor.tsx | 10 +++- src/components/Loader.tsx | 43 ++++++++++++++++ src/components/Post.tsx | 74 +++++----------------------- src/components/PostActionButtons.tsx | 31 ++++++++++++ src/components/PostActionModal.tsx | 70 ++++++++++++++++++++++++++ src/components/PostReplies.tsx | 42 ++++++++++++++++ src/components/Reply.tsx | 54 ++++++++++++++++++++ src/components/UserPosts.tsx | 40 +++++---------- 14 files changed, 344 insertions(+), 97 deletions(-) create mode 100644 src/components/Loader.tsx create mode 100644 src/components/PostActionButtons.tsx create mode 100644 src/components/PostActionModal.tsx create mode 100644 src/components/PostReplies.tsx create mode 100644 src/components/Reply.tsx 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} +
    ); }