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 ( - -
-
- -
-
- setOpen(false)} - fullScreen={false} - maxWidth="sm" - fullWidth - > - - -
- ) -} - -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) => ( + + + + ))} +
+ +
+
+ Showing {posts.length ? pagination.offset + 1 : 0}โ€“ + {posts.length ? pagination.offset + posts.length : 0} of {pagination.total_count ?? 0} +
+
+ + + Page {page} / {totalPages} + + +
+
+
+ ) +} 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 ( +