OpChan/app/src/components/PostCard.tsx

174 lines
5.7 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
2025-10-03 19:00:01 +05:30
import type { Post } from '@opchan/core';
import { useAuth, useContent, usePermissions } from '@/hooks';
interface PostCardProps {
2025-10-03 19:00:01 +05:30
post: Post;
}
2025-10-03 19:00:01 +05:30
const PostCard: React.FC<PostCardProps> = ({ post }) => {
const {
bookmarks,
pending,
vote,
togglePostBookmark,
toggleFollow,
isFollowing,
2025-10-03 19:00:01 +05:30
cells,
commentsByPost,
} = useContent();
const permissions = usePermissions();
2025-10-03 19:00:01 +05:30
const { currentUser } = useAuth();
2025-10-03 19:00:01 +05:30
const cellName = cells.find(c => c.id === post.cellId)?.name || 'unknown';
const commentCount = commentsByPost[post.id]?.length || 0;
const isPending = pending.isPending(post.id);
2025-10-03 19:00:01 +05:30
const isBookmarked = bookmarks.some(
b => b.targetId === post.id && b.type === 'post'
);
const [bookmarkLoading, setBookmarkLoading] = React.useState(false);
const [followLoading, setFollowLoading] = React.useState(false);
const isOwnPost = currentUser?.address === post.author;
const isFollowingAuthor = isFollowing(post.author);
2025-08-30 18:34:50 +05:30
2025-10-03 19:00:01 +05:30
const score = post.upvotes.length - post.downvotes.length;
const userUpvoted = Boolean(
post.upvotes.some(v => v.author === currentUser?.address)
);
const userDownvoted = Boolean(
post.downvotes.some(v => v.author === currentUser?.address)
);
const contentText =
typeof post.content === 'string'
? post.content
: String(post.content ?? '');
2025-08-30 18:34:50 +05:30
const contentPreview =
contentText.length > 200
? contentText.substring(0, 200) + '...'
: contentText;
2025-08-30 18:34:50 +05:30
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault();
2025-10-03 19:00:01 +05:30
await vote({ targetId: post.id, isUpvote });
};
2025-09-05 17:24:29 +05:30
const handleBookmark = async (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setBookmarkLoading(true);
try {
2025-10-03 19:00:01 +05:30
await togglePostBookmark(post, post.cellId);
} finally {
setBookmarkLoading(false);
}
2025-09-05 17:24:29 +05:30
};
const handleFollow = async (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setFollowLoading(true);
try {
await toggleFollow(post.author);
} finally {
setFollowLoading(false);
}
};
return (
2025-11-20 15:41:29 -05:00
<div className="border-b border-border/30 py-3 px-4 text-sm">
<div className="flex items-start gap-3">
2025-11-17 18:29:28 -05:00
{/* Inline vote display */}
<button
2025-11-20 15:41:29 -05:00
className={`${userUpvoted ? 'text-primary' : 'text-muted-foreground'} hover:text-primary text-lg`}
2025-11-17 18:29:28 -05:00
onClick={e => handleVote(e, true)}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
>
</button>
2025-11-20 15:41:29 -05:00
<span className={`font-mono text-base min-w-[2ch] text-center ${score > 0 ? 'text-primary' : score < 0 ? 'text-red-400' : 'text-muted-foreground'}`}>
2025-11-17 18:29:28 -05:00
{score}
</span>
<button
2025-11-20 15:41:29 -05:00
className={`${userDownvoted ? 'text-blue-400' : 'text-muted-foreground'} hover:text-blue-400 text-lg`}
2025-11-17 18:29:28 -05:00
onClick={e => handleVote(e, false)}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
>
</button>
2025-11-14 14:02:27 -05:00
2025-11-17 18:29:28 -05:00
{/* Content - all inline */}
<div className="flex-1 min-w-0">
2025-11-20 15:41:29 -05:00
<div className="flex flex-wrap items-baseline gap-1.5">
2025-11-14 14:02:27 -05:00
<Link
to={cellName ? `/cell/${post.cellId}` : '#'}
2025-11-20 15:41:29 -05:00
className="text-primary hover:underline text-sm font-medium"
2025-11-14 14:02:27 -05:00
onClick={e => {
if (!cellName) e.preventDefault();
}}
>
r/{cellName}
</Link>
2025-11-17 18:29:28 -05:00
<span className="text-muted-foreground">·</span>
2025-11-20 15:41:29 -05:00
<Link to={`/post/${post.id}`} className="text-foreground hover:underline font-medium text-lg">
2025-11-17 18:29:28 -05:00
{post.title}
</Link>
2025-11-20 15:41:29 -05:00
<span className="text-muted-foreground text-xs">
2025-11-17 18:29:28 -05:00
by {post.author.slice(0, 6)}...{post.author.slice(-4)}
</span>
2025-11-20 15:41:29 -05:00
<span className="text-muted-foreground text-xs">·</span>
<span className="text-muted-foreground text-xs">
2025-11-14 14:02:27 -05:00
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
2025-11-20 15:41:29 -05:00
<span className="text-muted-foreground text-xs">·</span>
<Link to={`/post/${post.id}`} className="text-muted-foreground hover:underline text-xs">
2025-11-14 14:02:27 -05:00
{commentCount} {commentCount === 1 ? 'reply' : 'replies'}
</Link>
2025-11-20 15:41:29 -05:00
<span className="text-muted-foreground text-xs">·</span>
<button
2025-11-14 14:02:27 -05:00
onClick={handleBookmark}
disabled={bookmarkLoading}
2025-11-20 15:41:29 -05:00
className="text-muted-foreground hover:underline text-xs"
>
2025-11-14 14:02:27 -05:00
{isBookmarked ? 'unsave' : 'save'}
</button>
{currentUser && !isOwnPost && (
<>
<span className="text-muted-foreground text-xs">·</span>
<button
onClick={handleFollow}
disabled={followLoading}
className={`hover:underline text-xs ${isFollowingAuthor ? 'text-red-400' : 'text-muted-foreground'}`}
>
{isFollowingAuthor ? 'unfollow' : 'follow'}
</button>
</>
)}
2025-11-17 18:29:28 -05:00
{isPending && (
<>
2025-11-20 15:41:29 -05:00
<span className="text-muted-foreground text-xs">·</span>
<span className="text-yellow-400 text-xs">syncing</span>
2025-11-17 18:29:28 -05:00
</>
)}
2025-09-10 16:57:38 +05:30
</div>
</div>
</div>
</div>
);
};
2025-08-30 18:34:50 +05:30
export default PostCard;