diff --git a/convex/posts.ts b/convex/posts.ts index e4b6368..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'; @@ -46,6 +47,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); }, @@ -85,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/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 ( -
+
; + return
; } diff --git a/src/app/posts/[id]/edit/page.tsx b/src/app/posts/[id]/edit/page.tsx index 899f183..214f965 100644 --- a/src/app/posts/[id]/edit/page.tsx +++ b/src/app/posts/[id]/edit/page.tsx @@ -1,26 +1,69 @@ -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: + router.push(`/posts/${post?._id}`); + return; + } + } + return ( -
- +
+ {user.isLoaded && post !== undefined ? ( + + ) : ( +
+ + Loading... +
+ )}
); } diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index 1a0b329..b5b12c0 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -1,15 +1,66 @@ 'use client'; +import Editor, { SubmitActionParams } from '@/components/Editor'; import Post from '@/components/Post'; +import PostReplies from '@/components/PostReplies'; import SpinningLoader from '@/components/SpinningLoader'; -import { useQuery } from 'convex/react'; +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'; -export default function PostPage({ params }: { params: { id: string } }) { +export default function PostPage({ + params, + searchParams, +}: { + params: { id: string }; + 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, + 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 @@ -18,14 +69,46 @@ export default function PostPage({ params }: { params: { id: string } }) { return (
-
- {post === undefined ? ( - +
+ {post !== undefined && user.isLoaded ? ( + <> +
+ +
+
+ +
+
+
+ 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/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 ( -
+
& { user: UserDto }; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 562cf3e..47cdb8a 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -2,24 +2,43 @@ 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 } from '../../convex/_generated/dataModel'; +import { FormEvent, useEffect, useState } from 'react'; +import { Doc, Id } from '../../convex/_generated/dataModel'; import PostContent from './PostContent'; -export default function Editor({ post }: { post?: Doc<'posts'> }) { - const { user, isSignedIn } = useUser(); +export type SubmitActionParams = { + clerkUserId: string; + parentPostId?: Id<'posts'>; + post?: Doc<'posts'>; + currentDoc: string[]; +}; + +export default function Editor({ + clerkUserId, + parentPostId, + post, + submitAction, + autofocus = true, +}: { + clerkUserId: string; + parentPostId?: Id<'posts'>; + post?: Doc<'posts'>; + submitAction: ({ + clerkUserId, + parentPostId, + post, + currentDoc, + }: SubmitActionParams) => Promise; + autofocus?: boolean; +}) { const editorTheme = new Compartment(); const [currentDoc, setCurrentDoc] = useState(post?.content ?? ['']); - const createOrUpdatePost = useMutation(api.posts.createOrUpdatePost); const router = useRouter(); const [creatingOrUpdating, setCreatingOrUpdating] = useState(false); @@ -36,44 +55,44 @@ export default function Editor({ post }: { post?: Doc<'posts'> }) { ]; const [mode, setMode] = useState<'write' | 'preview'>('write'); - const { editorRef } = useCodeMirror({ doc: currentDoc, extensions }); + const { editorRef, editorView } = useCodeMirror({ + doc: currentDoc, + extensions, + }); + + useEffect(() => { + if (editorView !== null && autofocus) { + editorView.focus(); + } + }, [autofocus, editorView]); async function handleSubmit(e: FormEvent) { e.preventDefault(); + try { - if (isSignedIn !== true) { - router.push('/login'); - return; - } - setCreatingOrUpdating(true); - - const result = await createOrUpdatePost({ - 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 (
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/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 6129124..fda7603 100644 --- a/src/components/Post.tsx +++ b/src/components/Post.tsx @@ -1,26 +1,28 @@ '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 } from 'react-icons/bi'; 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({ post, limit = true, + showReply = true, }: { post: PostWithUserDto; 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(); @@ -29,26 +31,9 @@ export default function Post({ }); const dayOfMonth = createdPostDate.getDate(); const content = limit ? post.content.slice(0, 10) : post.content; - - // 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]); + const postLink = `/posts/${post._id}`; async function handleDeleteClick() { - setModalOpen(false); const result = confirm('Are you sure you want to delete this post?'); if (result === false) { @@ -59,8 +44,8 @@ export default function Post({ } return ( -
-
+
+
user profile image
-
+
-
-
{username}
- -
- {`${postMonth} ${dayOfMonth}, ${postYear}`} -
-
-
- +
-
    -
  • - setModalOpen(false)} - href={`/posts/${post._id}/edit`} - > - Edit post - -
  • -
  • - -
  • -
+ {username} +
+
+ +
+ {`${postMonth} ${dayOfMonth}, ${postYear}`} +
+ {isLoaded && isSignedIn && user.id === post.user.clerkUserId ? ( + + ) : null}
@@ -118,13 +83,16 @@ export default function Post({ {limit && post.content.length > 10 ? ( Show more ) : 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/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/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} +
); } 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}`}