diff --git a/client/src/app/test/user-posts/page.tsx b/client/src/app/test/user-posts/page.tsx
new file mode 100644
index 00000000..baa216bb
--- /dev/null
+++ b/client/src/app/test/user-posts/page.tsx
@@ -0,0 +1,37 @@
+"use client"
+
+import { useMemo } from 'react'
+import UserPosts from '@/components/UserPosts'
+
+export type UserPostsTestPageProps = {
+ searchParams?: {
+ userId?: string
+ pageSize?: string
+ }
+}
+
+export default function UserPostsTestPage({ searchParams }: UserPostsTestPageProps) {
+ const userId = searchParams?.userId || ''
+ const pageSize = useMemo(() => {
+ const parsed = Number(searchParams?.pageSize)
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 10
+ }, [searchParams?.pageSize])
+
+ if (!userId) {
+ return (
+
+ User posts test page
+
+ Provide a userId query string parameter to preview posts for a
+ specific user.
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/client/src/components/UserPosts/UserPosts.jsx b/client/src/components/UserPosts/UserPosts.jsx
deleted file mode 100644
index d80fcc6f..00000000
--- a/client/src/components/UserPosts/UserPosts.jsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useEffect, useState } from 'react';
-import PropTypes from 'prop-types';
-import { makeStyles } from '@material-ui/core/styles';
-import { Button, Dialog } from '@material-ui/core';
-import PaginatedPostsList from '../Post/PaginatedPostsList';
-import ErrorBoundary from '../ErrorBoundary';
-import { useHistory } from 'react-router-dom';
-import { useSelector } from 'react-redux';
-import SubmitPost from '../SubmitPost/SubmitPost';
-
-const useStyles = makeStyles((theme) => ({
- root: {
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- width: '100%',
- },
- header: {
- width: '100%',
- display: 'flex',
- justifyContent: 'center',
- marginBottom: '20px',
- },
- list: {
- width: '100%',
- marginBottom: 10,
- },
- emptyState: {
- textAlign: 'center',
- padding: theme.spacing(4),
- },
- loadingContainer: {
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- minHeight: '200px',
- width: '100%',
- },
- loadingText: {
- marginTop: theme.spacing(2),
- },
-}))
-
-export default function UserPosts({ userId }) {
- const classes = useStyles()
- const [open, setOpen] = useState(false)
- const history = useHistory()
- const loggedIn = useSelector((state) => !!state.user.data._id)
- const currentUser = useSelector((state) => state.user.data)
-
- // Check if viewing own profile
- const isOwnProfile = userId === currentUser?._id
-
- useEffect(() => {
- window.scrollTo(0, 0)
- }, [])
-
- return (
-
-
-
-
- )
-}
-
-UserPosts.propTypes = {
- userId: PropTypes.string.isRequired,
-}
\ No newline at end of file
diff --git a/client/src/components/UserPosts/UserPosts.tsx b/client/src/components/UserPosts/UserPosts.tsx
new file mode 100644
index 00000000..2018c6aa
--- /dev/null
+++ b/client/src/components/UserPosts/UserPosts.tsx
@@ -0,0 +1,243 @@
+"use client"
+
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery } from '@apollo/client'
+import {
+ GET_USER_POSTS,
+ type UserPost,
+ type UserPostsResponse,
+} from '@/graphql/queries/userPosts'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import Button from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { cn } from '@/lib/utils'
+
+type UserPostsProps = {
+ userId: string
+ pageSize?: number
+ className?: string
+}
+
+type PaginationInfo = {
+ total_count: number
+ limit: number
+ offset: number
+}
+
+function formatDate(isoDate?: string): string {
+ if (!isoDate) return 'Unknown date'
+ const date = new Date(isoDate)
+ return date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+}
+
+function PostCard({ post }: { post: UserPost }) {
+ const commentCount = post.comments?.length ?? 0
+ const voteTotal = (post.upvotes ?? 0) - (post.downvotes ?? 0)
+
+ return (
+
+
+
+ {post.title}
+
+
+ {post.creator?.username ? `@${post.creator.username}` : 'Unknown author'} ยท {formatDate(post.created)}
+
+
+
+ {post.text && (
+ {post.text}
+ )}
+ {post.url && (
+
+ {post.url}
+
+ )}
+
+
+
+
+ ๐ {post.upvotes ?? 0}
+
+
+ ๐ {post.downvotes ?? 0}
+
+
+ ๐ฌ {commentCount}
+
+
+
+ Score: {voteTotal}
+
+
+
+ )
+}
+
+function LoadingList() {
+ return (
+
+ {[0, 1, 2].map((key) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+function EmptyState() {
+ return (
+
+
๐
+
No posts yet
+
+ This user has not published any posts.
+
+
+ )
+}
+
+function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
+ return (
+
+ Something went wrong
+
+ {message}
+
+
+
+ )
+}
+
+export default function UserPosts({ userId, pageSize = 15, className }: UserPostsProps) {
+ const [page, setPage] = useState(1)
+
+ const offset = useMemo(() => (page - 1) * pageSize, [page, pageSize])
+
+ const { data, loading, error, refetch } = useQuery(GET_USER_POSTS, {
+ variables: {
+ limit: pageSize,
+ offset,
+ userId,
+ searchKey: '',
+ sortOrder: 'new',
+ },
+ fetchPolicy: 'cache-and-network',
+ nextFetchPolicy: 'cache-first',
+ })
+
+ useEffect(() => {
+ window.scrollTo(0, 0)
+ }, [])
+
+ useEffect(() => {
+ setPage(1)
+ }, [userId, pageSize])
+
+ const posts = data?.posts.entities ?? []
+ const pagination: PaginationInfo = data?.posts.pagination ?? {
+ limit: pageSize,
+ offset,
+ total_count: 0,
+ }
+
+ const totalPages = Math.max(1, Math.ceil((pagination.total_count ?? 0) / pageSize))
+
+ const handleNext = () => {
+ setPage((current) => Math.min(totalPages, current + 1))
+ }
+
+ const handlePrev = () => {
+ setPage((current) => Math.max(1, current - 1))
+ }
+
+ const handleRefresh = () => {
+ refetch()
+ }
+
+ return (
+
+
+
+
Posts
+
User activity
+
+
+
+
+
+
+ {loading && !posts.length ? : null}
+
+ {error ? (
+
+ ) : posts.length === 0 && !loading ? (
+
+ ) : null}
+
+
+ {posts.map((post) => (
+
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/client/src/components/UserPosts/index.js b/client/src/components/UserPosts/index.js
deleted file mode 100644
index 326dfac6..00000000
--- a/client/src/components/UserPosts/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './UserPosts'
\ No newline at end of file
diff --git a/client/src/components/UserPosts/index.ts b/client/src/components/UserPosts/index.ts
new file mode 100644
index 00000000..b7434f70
--- /dev/null
+++ b/client/src/components/UserPosts/index.ts
@@ -0,0 +1,2 @@
+export { default } from './UserPosts'
+export * from './UserPosts'
diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx
new file mode 100644
index 00000000..62ed8739
--- /dev/null
+++ b/client/src/components/ui/alert.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+type AlertVariant = 'default' | 'destructive'
+
+interface AlertProps extends React.HTMLAttributes {
+ variant?: AlertVariant
+}
+
+const variantClasses: Record = {
+ default: 'bg-muted text-foreground',
+ destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
+}
+
+export function Alert({ className, variant = 'default', ...props }: AlertProps) {
+ return (
+ svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
+ variantClasses[variant],
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export function AlertTitle({ className, ...props }: React.HTMLAttributes
) {
+ return
+}
+
+export function AlertDescription({ className, ...props }: React.HTMLAttributes) {
+ return
+}
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 00000000..7b9c640b
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+type ButtonVariant = 'default' | 'outline' | 'ghost' | 'secondary'
+
+type ButtonProps = React.ButtonHTMLAttributes & {
+ variant?: ButtonVariant
+}
+
+const variantStyles: Record = {
+ default: 'bg-primary text-white hover:bg-primary/90',
+ outline: 'border border-border bg-transparent text-foreground hover:bg-muted',
+ ghost: 'bg-transparent text-foreground hover:bg-muted',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/90',
+}
+
+export const Button = React.forwardRef(
+ ({ className, variant = 'default', type = 'button', ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Button.displayName = 'Button'
+
+export default Button
diff --git a/client/src/components/ui/card.tsx b/client/src/components/ui/card.tsx
new file mode 100644
index 00000000..6066ab39
--- /dev/null
+++ b/client/src/components/ui/card.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export interface CardProps extends React.HTMLAttributes {}
+
+export function Card({ className, ...props }: CardProps) {
+ return (
+
+ )
+}
+
+export function CardHeader({ className, ...props }: React.HTMLAttributes) {
+ return
+}
+
+export function CardTitle({ className, ...props }: React.HTMLAttributes) {
+ return
+}
+
+export function CardDescription({ className, ...props }: React.HTMLAttributes) {
+ return
+}
+
+export function CardContent({ className, ...props }: React.HTMLAttributes) {
+ return
+}
+
+export function CardFooter({ className, ...props }: React.HTMLAttributes) {
+ return
+}
diff --git a/client/src/components/ui/skeleton.tsx b/client/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..5a7b1f9d
--- /dev/null
+++ b/client/src/components/ui/skeleton.tsx
@@ -0,0 +1,10 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+export interface SkeletonProps extends React.HTMLAttributes {}
+
+export function Skeleton({ className, ...props }: SkeletonProps) {
+ return
+}
+
+export default Skeleton
diff --git a/client/src/graphql/queries/userPosts.ts b/client/src/graphql/queries/userPosts.ts
new file mode 100644
index 00000000..7bc88013
--- /dev/null
+++ b/client/src/graphql/queries/userPosts.ts
@@ -0,0 +1,88 @@
+import { gql } from '@apollo/client'
+
+export const GET_USER_POSTS = gql`
+ query GetUserPosts(
+ $limit: Int!
+ $offset: Int!
+ $userId: String!
+ $searchKey: String! = ""
+ $sortOrder: String
+ ) {
+ posts(
+ limit: $limit
+ offset: $offset
+ userId: $userId
+ searchKey: $searchKey
+ sortOrder: $sortOrder
+ ) {
+ entities {
+ _id
+ userId
+ groupId
+ title
+ text
+ upvotes
+ downvotes
+ bookmarkedBy
+ created
+ url
+ rejectedBy
+ approvedBy
+ creator {
+ name
+ username
+ avatar
+ _id
+ contributorBadge
+ }
+ votes {
+ _id
+ type
+ }
+ comments {
+ _id
+ }
+ }
+ pagination {
+ total_count
+ limit
+ offset
+ }
+ }
+ }
+`
+
+export type UserPost = {
+ _id: string
+ userId: string
+ groupId?: string
+ title: string
+ text?: string
+ upvotes?: number
+ downvotes?: number
+ bookmarkedBy?: string[]
+ created?: string
+ url?: string
+ rejectedBy?: string[]
+ approvedBy?: string[]
+ creator?: {
+ _id: string
+ name?: string
+ username?: string
+ avatar?: string
+ contributorBadge?: string
+ }
+ votes?: Array<{ _id: string; type?: string }>
+ comments?: Array<{ _id: string }>
+}
+
+export type UserPostsResponse = {
+ posts: {
+ entities: UserPost[]
+ pagination: {
+ total_count: number
+ limit: number
+ offset: number
+ }
+ }
+}
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts
new file mode 100644
index 00000000..be57ca5b
--- /dev/null
+++ b/client/src/lib/utils.ts
@@ -0,0 +1,3 @@
+export function cn(...classes: Array): string {
+ return classes.filter(Boolean).join(' ')
+}
diff --git a/client/src/views/Profile/ProfileView.jsx b/client/src/views/Profile/ProfileView.jsx
index abad1c99..4aa876d4 100644
--- a/client/src/views/Profile/ProfileView.jsx
+++ b/client/src/views/Profile/ProfileView.jsx
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types'
import AppBar from 'components/Navbars/ProfileHeader'
import LoadingSpinner from 'components/LoadingSpinner'
import { Link, Typography } from '@material-ui/core'
-import UserPosts from '../../components/UserPosts'
+import UserPosts from '@/components/UserPosts'
import ReputationDisplay from '../../components/Profile/ReputationDisplay'
const useStyles = makeStyles(({