2025-08-06 15:37:48 +05:30
|
|
|
import React from 'react';
|
|
|
|
|
import { Link } from 'react-router-dom';
|
2025-09-12 16:03:12 +05:30
|
|
|
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
|
2025-08-06 15:37:48 +05:30
|
|
|
import { formatDistanceToNow } from 'date-fns';
|
2025-10-03 19:00:01 +05:30
|
|
|
import type { Post } from '@opchan/core';
|
2025-08-11 12:14:19 +05:30
|
|
|
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
|
2025-08-11 12:23:08 +05:30
|
|
|
import { AuthorDisplay } from '@/components/ui/author-display';
|
2025-09-05 17:24:29 +05:30
|
|
|
import { BookmarkButton } from '@/components/ui/bookmark-button';
|
2025-09-10 15:04:24 +05:30
|
|
|
import { LinkRenderer } from '@/components/ui/link-renderer';
|
2025-10-03 19:00:01 +05:30
|
|
|
import { useAuth, useContent, usePermissions } from '@/hooks';
|
2025-09-11 08:54:26 +02:00
|
|
|
import { ShareButton } from '@/components/ui/ShareButton';
|
2025-08-06 15:37:48 +05:30
|
|
|
|
|
|
|
|
interface PostCardProps {
|
2025-10-03 19:00:01 +05:30
|
|
|
post: Post;
|
2025-08-06 15:37:48 +05:30
|
|
|
}
|
|
|
|
|
|
2025-10-03 19:00:01 +05:30
|
|
|
const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
|
|
|
|
const {
|
|
|
|
|
bookmarks,
|
|
|
|
|
pending,
|
|
|
|
|
vote,
|
|
|
|
|
togglePostBookmark,
|
|
|
|
|
cells,
|
|
|
|
|
commentsByPost,
|
|
|
|
|
} = useContent();
|
2025-09-25 21:52:40 +05:30
|
|
|
const permissions = usePermissions();
|
2025-10-03 19:00:01 +05:30
|
|
|
const { currentUser } = useAuth();
|
2025-09-25 21:52:40 +05:30
|
|
|
|
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-09-25 21:52:40 +05:30
|
|
|
|
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);
|
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 =
|
2025-09-25 21:52:40 +05:30
|
|
|
contentText.length > 200
|
|
|
|
|
? contentText.substring(0, 200) + '...'
|
|
|
|
|
: contentText;
|
2025-08-30 18:34:50 +05:30
|
|
|
|
2025-08-06 15:37:48 +05:30
|
|
|
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
|
2025-09-03 15:56:00 +05:30
|
|
|
e.preventDefault();
|
2025-10-03 19:00:01 +05:30
|
|
|
await vote({ targetId: post.id, isUpvote });
|
2025-08-06 15:37:48 +05:30
|
|
|
};
|
|
|
|
|
|
2025-09-05 17:24:29 +05:30
|
|
|
const handleBookmark = async (e?: React.MouseEvent) => {
|
|
|
|
|
if (e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}
|
2025-09-25 21:52:40 +05:30
|
|
|
setBookmarkLoading(true);
|
|
|
|
|
try {
|
2025-10-03 19:00:01 +05:30
|
|
|
await togglePostBookmark(post, post.cellId);
|
2025-09-25 21:52:40 +05:30
|
|
|
} finally {
|
|
|
|
|
setBookmarkLoading(false);
|
|
|
|
|
}
|
2025-09-05 17:24:29 +05:30
|
|
|
};
|
|
|
|
|
|
2025-08-06 15:37:48 +05:30
|
|
|
return (
|
2025-09-05 20:26:29 +05:30
|
|
|
<div className="thread-card mb-2">
|
2025-08-06 15:37:48 +05:30
|
|
|
<div className="flex">
|
|
|
|
|
{/* Voting column */}
|
|
|
|
|
<div className="flex flex-col items-center p-2 bg-cyber-muted/50 border-r border-cyber-muted">
|
2025-08-30 18:34:50 +05:30
|
|
|
<button
|
2025-08-06 15:37:48 +05:30
|
|
|
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
|
2025-08-30 18:34:50 +05:30
|
|
|
userUpvoted
|
|
|
|
|
? 'text-cyber-accent'
|
|
|
|
|
: 'text-cyber-neutral hover:text-cyber-accent'
|
2025-08-06 15:37:48 +05:30
|
|
|
}`}
|
2025-08-30 18:34:50 +05:30
|
|
|
onClick={e => handleVote(e, true)}
|
2025-09-25 21:52:40 +05:30
|
|
|
disabled={!permissions.canVote}
|
|
|
|
|
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
|
2025-08-06 15:37:48 +05:30
|
|
|
>
|
|
|
|
|
<ArrowUp className="w-5 h-5" />
|
|
|
|
|
</button>
|
2025-08-30 18:34:50 +05:30
|
|
|
|
|
|
|
|
<span
|
2025-10-03 19:00:01 +05:30
|
|
|
className={`text-sm font-medium px-1`}
|
2025-08-30 18:34:50 +05:30
|
|
|
>
|
2025-08-06 15:37:48 +05:30
|
|
|
{score}
|
|
|
|
|
</span>
|
2025-08-30 18:34:50 +05:30
|
|
|
|
|
|
|
|
<button
|
2025-08-06 15:37:48 +05:30
|
|
|
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
|
2025-08-30 18:34:50 +05:30
|
|
|
userDownvoted
|
|
|
|
|
? 'text-blue-400'
|
|
|
|
|
: 'text-cyber-neutral hover:text-blue-400'
|
2025-08-06 15:37:48 +05:30
|
|
|
}`}
|
2025-08-30 18:34:50 +05:30
|
|
|
onClick={e => handleVote(e, false)}
|
2025-09-25 21:52:40 +05:30
|
|
|
disabled={!permissions.canVote}
|
|
|
|
|
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
|
2025-08-06 15:37:48 +05:30
|
|
|
>
|
|
|
|
|
<ArrowDown className="w-5 h-5" />
|
|
|
|
|
</button>
|
2025-09-25 21:52:40 +05:30
|
|
|
{isPending && (
|
2025-09-04 13:27:47 +05:30
|
|
|
<span className="mt-1 text-[10px] text-yellow-400">syncing…</span>
|
|
|
|
|
)}
|
2025-08-06 15:37:48 +05:30
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content column */}
|
|
|
|
|
<div className="flex-1 p-3">
|
2025-09-10 16:57:38 +05:30
|
|
|
<div className="block hover:opacity-80">
|
2025-08-06 15:37:48 +05:30
|
|
|
{/* Post metadata */}
|
|
|
|
|
<div className="flex items-center text-xs text-cyber-neutral mb-2 space-x-2">
|
2025-10-03 19:00:01 +05:30
|
|
|
<Link
|
|
|
|
|
to={cellName ? `/cell/${post.cellId}` : "#"}
|
|
|
|
|
className="font-medium text-cyber-accent hover:underline focus:underline"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={e => {
|
|
|
|
|
if (!cellName) e.preventDefault();
|
|
|
|
|
}}
|
|
|
|
|
title={cellName ? `Go to /${cellName}` : undefined}
|
|
|
|
|
>
|
|
|
|
|
r/{cellName || 'unknown'}
|
|
|
|
|
</Link>
|
2025-08-06 15:37:48 +05:30
|
|
|
<span>•</span>
|
2025-08-11 12:23:08 +05:30
|
|
|
<span>Posted by u/</span>
|
2025-08-30 18:34:50 +05:30
|
|
|
<AuthorDisplay
|
2025-09-03 15:01:57 +05:30
|
|
|
address={post.author}
|
2025-08-11 12:23:08 +05:30
|
|
|
className="text-xs"
|
|
|
|
|
showBadge={false}
|
|
|
|
|
/>
|
2025-08-06 15:37:48 +05:30
|
|
|
<span>•</span>
|
2025-08-30 18:34:50 +05:30
|
|
|
<span>
|
|
|
|
|
{formatDistanceToNow(new Date(post.timestamp), {
|
|
|
|
|
addSuffix: true,
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
2025-10-03 19:00:01 +05:30
|
|
|
{'relevanceScore' in post &&
|
|
|
|
|
typeof (post as Post).relevanceScore === 'number' && (
|
|
|
|
|
<>
|
|
|
|
|
<span>•</span>
|
|
|
|
|
<RelevanceIndicator
|
|
|
|
|
score={(post as Post).relevanceScore as number}
|
|
|
|
|
details={
|
|
|
|
|
'relevanceDetails' in post
|
|
|
|
|
? (post as Post).relevanceDetails
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
type="post"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
showTooltip={true}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-08-06 15:37:48 +05:30
|
|
|
</div>
|
|
|
|
|
|
2025-09-10 16:57:38 +05:30
|
|
|
{/* Post title and content - clickable to navigate to post */}
|
2025-09-10 17:06:01 +05:30
|
|
|
<div className="block">
|
|
|
|
|
<Link to={`/post/${post.id}`} className="block">
|
|
|
|
|
<h2 className="text-lg font-semibold text-glow mb-2 hover:text-cyber-accent transition-colors">
|
|
|
|
|
{post.title}
|
|
|
|
|
</h2>
|
|
|
|
|
</Link>
|
2025-08-06 15:37:48 +05:30
|
|
|
|
2025-09-10 16:57:38 +05:30
|
|
|
{/* Post content preview */}
|
|
|
|
|
<p className="text-cyber-neutral text-sm leading-relaxed mb-3">
|
|
|
|
|
<LinkRenderer text={contentPreview} />
|
|
|
|
|
</p>
|
2025-09-10 17:06:01 +05:30
|
|
|
</div>
|
2025-08-06 15:37:48 +05:30
|
|
|
|
|
|
|
|
{/* Post actions */}
|
2025-09-05 17:24:29 +05:30
|
|
|
<div className="flex items-center justify-between text-xs text-cyber-neutral">
|
|
|
|
|
<div className="flex items-center space-x-4">
|
|
|
|
|
<div className="flex items-center space-x-1 hover:text-cyber-accent transition-colors">
|
|
|
|
|
<MessageSquare className="w-4 h-4" />
|
|
|
|
|
<span>{commentCount} comments</span>
|
|
|
|
|
</div>
|
|
|
|
|
{isPending && (
|
|
|
|
|
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
|
|
|
|
syncing…
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2025-09-11 08:54:26 +02:00
|
|
|
<ShareButton
|
2025-09-12 16:03:12 +05:30
|
|
|
size="sm"
|
2025-09-11 08:54:26 +02:00
|
|
|
url={`${window.location.origin}/post/${post.id}`}
|
|
|
|
|
title={post.title}
|
|
|
|
|
/>
|
2025-08-06 15:37:48 +05:30
|
|
|
</div>
|
2025-09-05 17:24:29 +05:30
|
|
|
<BookmarkButton
|
|
|
|
|
isBookmarked={isBookmarked}
|
|
|
|
|
loading={bookmarkLoading}
|
|
|
|
|
onClick={handleBookmark}
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
/>
|
2025-08-06 15:37:48 +05:30
|
|
|
</div>
|
2025-09-10 16:57:38 +05:30
|
|
|
</div>
|
2025-08-06 15:37:48 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-30 18:34:50 +05:30
|
|
|
export default PostCard;
|