2025-04-15 16:28:03 +05:30
|
|
|
import React, { useState } from 'react';
|
|
|
|
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
2025-07-30 13:22:06 +05:30
|
|
|
import { useForum } from '@/contexts/useForum';
|
|
|
|
|
import { useAuth } from '@/contexts/useAuth';
|
2025-04-15 16:28:03 +05:30
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea';
|
2025-04-24 17:35:31 +05:30
|
|
|
import { ArrowLeft, ArrowUp, ArrowDown, Clock, MessageCircle, Send, RefreshCw, Eye, Loader2 } from 'lucide-react';
|
2025-04-15 16:28:03 +05:30
|
|
|
import { formatDistanceToNow } from 'date-fns';
|
2025-04-16 14:45:27 +05:30
|
|
|
import { Comment } from '@/types';
|
2025-04-22 11:05:49 +05:30
|
|
|
import { CypherImage } from './ui/CypherImage';
|
2025-04-24 14:31:00 +05:30
|
|
|
import { Badge } from '@/components/ui/badge';
|
2025-08-11 12:14:19 +05:30
|
|
|
import { RelevanceIndicator } from './ui/relevance-indicator';
|
2025-04-15 16:28:03 +05:30
|
|
|
|
|
|
|
|
const PostDetail = () => {
|
|
|
|
|
const { postId } = useParams<{ postId: string }>();
|
|
|
|
|
const navigate = useNavigate();
|
2025-04-22 10:39:32 +05:30
|
|
|
const {
|
|
|
|
|
posts,
|
|
|
|
|
comments,
|
|
|
|
|
getCommentsByPost,
|
|
|
|
|
createComment,
|
|
|
|
|
votePost,
|
|
|
|
|
voteComment,
|
|
|
|
|
getCellById,
|
|
|
|
|
isInitialLoading,
|
|
|
|
|
isPostingComment,
|
|
|
|
|
isVoting,
|
|
|
|
|
isRefreshing,
|
2025-06-06 16:45:14 +05:30
|
|
|
refreshData,
|
|
|
|
|
moderateComment,
|
|
|
|
|
moderateUser
|
2025-04-22 10:39:32 +05:30
|
|
|
} = useForum();
|
2025-04-24 14:31:00 +05:30
|
|
|
const { currentUser, isAuthenticated, verificationStatus } = useAuth();
|
2025-04-15 16:28:03 +05:30
|
|
|
const [newComment, setNewComment] = useState('');
|
|
|
|
|
|
2025-04-24 14:31:00 +05:30
|
|
|
if (!postId) return <div>Invalid post ID</div>;
|
|
|
|
|
|
|
|
|
|
if (isInitialLoading) {
|
2025-04-15 16:28:03 +05:30
|
|
|
return (
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="container mx-auto px-4 py-16 text-center">
|
|
|
|
|
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
|
|
|
|
|
<p className="text-lg font-medium text-muted-foreground">Loading Post...</p>
|
2025-04-15 16:28:03 +05:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const post = posts.find(p => p.id === postId);
|
|
|
|
|
|
|
|
|
|
if (!post) {
|
|
|
|
|
return (
|
2025-04-24 14:31:00 +05:30
|
|
|
<div className="container mx-auto px-4 py-6 text-center">
|
|
|
|
|
<h2 className="text-xl font-bold mb-4">Post not found</h2>
|
|
|
|
|
<p className="mb-4">The post you're looking for doesn't exist or has been removed.</p>
|
|
|
|
|
<Button asChild>
|
|
|
|
|
<Link to="/">Go back home</Link>
|
|
|
|
|
</Button>
|
2025-04-15 16:28:03 +05:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cell = getCellById(post.cellId);
|
2025-04-24 14:31:00 +05:30
|
|
|
const postComments = getCommentsByPost(post.id);
|
2025-04-15 16:28:03 +05:30
|
|
|
|
2025-06-06 16:42:00 +05:30
|
|
|
const isCellAdmin = currentUser && cell && currentUser.address === cell.signature;
|
|
|
|
|
const visibleComments = isCellAdmin
|
|
|
|
|
? postComments
|
|
|
|
|
: postComments.filter(comment => !comment.moderated);
|
|
|
|
|
|
2025-04-15 16:28:03 +05:30
|
|
|
const handleCreateComment = async (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (!newComment.trim()) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await createComment(postId, newComment);
|
|
|
|
|
if (result) {
|
|
|
|
|
setNewComment('');
|
|
|
|
|
}
|
2025-04-22 10:39:32 +05:30
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error creating comment:", error);
|
2025-04-15 16:28:03 +05:30
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleVotePost = async (isUpvote: boolean) => {
|
2025-04-24 14:31:00 +05:30
|
|
|
if (verificationStatus !== 'verified-owner') return;
|
2025-04-15 16:28:03 +05:30
|
|
|
await votePost(post.id, isUpvote);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleVoteComment = async (commentId: string, isUpvote: boolean) => {
|
2025-04-24 14:31:00 +05:30
|
|
|
if (verificationStatus !== 'verified-owner') return;
|
2025-04-15 16:28:03 +05:30
|
|
|
await voteComment(commentId, isUpvote);
|
|
|
|
|
};
|
|
|
|
|
|
2025-04-22 10:39:32 +05:30
|
|
|
const isPostUpvoted = currentUser && post.upvotes.some(vote => vote.author === currentUser.address);
|
|
|
|
|
const isPostDownvoted = currentUser && post.downvotes.some(vote => vote.author === currentUser.address);
|
2025-04-15 16:28:03 +05:30
|
|
|
|
|
|
|
|
const isCommentVoted = (comment: Comment, isUpvote: boolean) => {
|
|
|
|
|
if (!currentUser) return false;
|
2025-04-22 10:39:32 +05:30
|
|
|
const votes = isUpvote ? comment.upvotes : comment.downvotes;
|
|
|
|
|
return votes.some(vote => vote.author === currentUser.address);
|
2025-04-15 16:28:03 +05:30
|
|
|
};
|
|
|
|
|
|
2025-04-24 14:31:00 +05:30
|
|
|
const getIdentityImageUrl = (address: string) => {
|
|
|
|
|
return `https://api.dicebear.com/7.x/identicon/svg?seed=${address}`;
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-06 16:42:00 +05:30
|
|
|
const handleModerateComment = async (commentId: string) => {
|
|
|
|
|
const reason = window.prompt('Enter a reason for moderation (optional):') || undefined;
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
await moderateComment(cell.id, commentId, reason, cell.signature);
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-06 16:45:14 +05:30
|
|
|
const handleModerateUser = async (userAddress: string) => {
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
const reason = window.prompt('Reason for moderating this user? (optional)') || undefined;
|
|
|
|
|
await moderateUser(cell.id, userAddress, reason, cell.signature);
|
|
|
|
|
};
|
|
|
|
|
|
2025-04-15 16:28:03 +05:30
|
|
|
return (
|
2025-04-24 14:31:00 +05:30
|
|
|
<div className="container mx-auto px-4 py-6">
|
|
|
|
|
<div className="mb-6">
|
2025-04-22 10:39:32 +05:30
|
|
|
<Button
|
2025-04-24 14:31:00 +05:30
|
|
|
onClick={() => navigate(`/cell/${post.cellId}`)}
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mb-4"
|
2025-04-22 10:39:32 +05:30
|
|
|
>
|
2025-04-24 14:31:00 +05:30
|
|
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
|
|
|
|
Back to /{cell?.name || 'cell'}/
|
2025-04-22 10:39:32 +05:30
|
|
|
</Button>
|
2025-04-24 14:31:00 +05:30
|
|
|
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="border border-muted rounded-sm p-3 mb-6">
|
|
|
|
|
<div className="flex gap-3 items-start">
|
|
|
|
|
<div className="flex flex-col items-center w-6 pt-1">
|
|
|
|
|
<button
|
|
|
|
|
className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostUpvoted ? 'text-primary' : ''}`}
|
|
|
|
|
onClick={() => handleVotePost(true)}
|
|
|
|
|
disabled={verificationStatus !== 'verified-owner' || isVoting}
|
|
|
|
|
title={verificationStatus === 'verified-owner' ? "Upvote" : "Full access required to vote"}
|
|
|
|
|
>
|
|
|
|
|
<ArrowUp className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
<span className="text-sm font-medium py-1">{post.upvotes.length - post.downvotes.length}</span>
|
|
|
|
|
<button
|
|
|
|
|
className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostDownvoted ? 'text-primary' : ''}`}
|
|
|
|
|
onClick={() => handleVotePost(false)}
|
|
|
|
|
disabled={verificationStatus !== 'verified-owner' || isVoting}
|
|
|
|
|
title={verificationStatus === 'verified-owner' ? "Downvote" : "Full access required to vote"}
|
|
|
|
|
>
|
|
|
|
|
<ArrowDown className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<h2 className="text-xl font-bold mb-2 text-foreground">{post.title}</h2>
|
|
|
|
|
<p className="text-base mb-4 text-foreground/90">{post.content}</p>
|
|
|
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
|
|
|
<span className="flex items-center">
|
|
|
|
|
<Clock className="w-3 h-3 mr-1" />
|
|
|
|
|
{formatDistanceToNow(post.timestamp, { addSuffix: true })}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex items-center">
|
|
|
|
|
<MessageCircle className="w-3 h-3 mr-1" />
|
|
|
|
|
{postComments.length} {postComments.length === 1 ? 'comment' : 'comments'}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="truncate max-w-[150px]">
|
|
|
|
|
{post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)}
|
|
|
|
|
</span>
|
2025-08-11 12:14:19 +05:30
|
|
|
{post.relevanceScore !== undefined && (
|
|
|
|
|
<RelevanceIndicator
|
|
|
|
|
score={post.relevanceScore}
|
|
|
|
|
details={post.relevanceDetails}
|
|
|
|
|
type="post"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
showTooltip={true}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-04-24 17:35:31 +05:30
|
|
|
</div>
|
2025-04-15 16:28:03 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-04-24 14:31:00 +05:30
|
|
|
{verificationStatus === 'verified-owner' ? (
|
2025-04-15 16:28:03 +05:30
|
|
|
<div className="mb-8">
|
|
|
|
|
<form onSubmit={handleCreateComment}>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Textarea
|
|
|
|
|
placeholder="Add a comment..."
|
|
|
|
|
value={newComment}
|
|
|
|
|
onChange={(e) => setNewComment(e.target.value)}
|
2025-04-24 17:35:31 +05:30
|
|
|
className="flex-1 bg-secondary/40 border-muted resize-none rounded-sm text-sm p-2"
|
2025-04-22 10:39:32 +05:30
|
|
|
disabled={isPostingComment}
|
2025-04-15 16:28:03 +05:30
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
2025-04-22 10:39:32 +05:30
|
|
|
disabled={isPostingComment || !newComment.trim()}
|
2025-04-15 16:28:03 +05:30
|
|
|
size="icon"
|
|
|
|
|
>
|
|
|
|
|
<Send className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
2025-04-24 14:31:00 +05:30
|
|
|
) : verificationStatus === 'verified-none' ? (
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="mb-8 p-3 border border-muted rounded-sm bg-secondary/30">
|
|
|
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
|
|
|
<Eye className="w-4 h-4 text-muted-foreground" />
|
2025-04-24 14:31:00 +05:30
|
|
|
<h3 className="font-medium">Read-Only Mode</h3>
|
|
|
|
|
</div>
|
2025-04-24 17:35:31 +05:30
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-04-24 14:31:00 +05:30
|
|
|
Your wallet has been verified but does not contain any Ordinal Operators.
|
|
|
|
|
You can browse threads but cannot comment or vote.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-04-15 16:28:03 +05:30
|
|
|
) : (
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="mb-8 p-3 border border-muted rounded-sm bg-secondary/30 text-center">
|
|
|
|
|
<p className="text-sm mb-2">Connect wallet and verify Ordinal ownership to comment</p>
|
2025-04-15 16:28:03 +05:30
|
|
|
<Button asChild size="sm">
|
|
|
|
|
<Link to="/">Go to Home</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{postComments.length === 0 ? (
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="text-center py-6 text-muted-foreground">
|
2025-04-15 16:28:03 +05:30
|
|
|
<p>No comments yet</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-06-06 16:42:00 +05:30
|
|
|
visibleComments.map(comment => (
|
2025-04-24 17:35:31 +05:30
|
|
|
<div key={comment.id} className="comment-card" id={`comment-${comment.id}`}>
|
2025-04-15 16:28:03 +05:30
|
|
|
<div className="flex gap-2 items-start">
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="flex flex-col items-center w-5 pt-0.5">
|
2025-04-15 16:28:03 +05:30
|
|
|
<button
|
2025-04-24 17:35:31 +05:30
|
|
|
className={`p-0.5 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isCommentVoted(comment, true) ? 'text-primary' : ''}`}
|
2025-04-15 16:28:03 +05:30
|
|
|
onClick={() => handleVoteComment(comment.id, true)}
|
2025-04-24 14:31:00 +05:30
|
|
|
disabled={verificationStatus !== 'verified-owner' || isVoting}
|
|
|
|
|
title={verificationStatus === 'verified-owner' ? "Upvote" : "Full access required to vote"}
|
2025-04-15 16:28:03 +05:30
|
|
|
>
|
|
|
|
|
<ArrowUp className="w-4 h-4" />
|
|
|
|
|
</button>
|
2025-04-24 17:35:31 +05:30
|
|
|
<span className="text-xs font-medium py-0.5">{comment.upvotes.length - comment.downvotes.length}</span>
|
2025-04-15 16:28:03 +05:30
|
|
|
<button
|
2025-04-24 17:35:31 +05:30
|
|
|
className={`p-0.5 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isCommentVoted(comment, false) ? 'text-primary' : ''}`}
|
2025-04-15 16:28:03 +05:30
|
|
|
onClick={() => handleVoteComment(comment.id, false)}
|
2025-04-24 14:31:00 +05:30
|
|
|
disabled={verificationStatus !== 'verified-owner' || isVoting}
|
|
|
|
|
title={verificationStatus === 'verified-owner' ? "Downvote" : "Full access required to vote"}
|
2025-04-15 16:28:03 +05:30
|
|
|
>
|
|
|
|
|
<ArrowDown className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-04-24 17:35:31 +05:30
|
|
|
<div className="flex-1 pt-0.5">
|
|
|
|
|
<div className="flex justify-between items-center mb-1.5">
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
2025-04-24 14:31:00 +05:30
|
|
|
<CypherImage
|
|
|
|
|
src={getIdentityImageUrl(comment.authorAddress)}
|
|
|
|
|
alt={comment.authorAddress.slice(0, 6)}
|
2025-04-24 17:35:31 +05:30
|
|
|
className="rounded-sm w-5 h-5 bg-secondary"
|
2025-04-24 14:31:00 +05:30
|
|
|
/>
|
2025-04-24 17:35:31 +05:30
|
|
|
<span className="text-xs text-muted-foreground">
|
2025-04-24 14:31:00 +05:30
|
|
|
{comment.authorAddress.slice(0, 6)}...{comment.authorAddress.slice(-4)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2025-08-11 12:14:19 +05:30
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{comment.relevanceScore !== undefined && (
|
|
|
|
|
<RelevanceIndicator
|
|
|
|
|
score={comment.relevanceScore}
|
|
|
|
|
details={comment.relevanceDetails}
|
|
|
|
|
type="comment"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
showTooltip={true}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{formatDistanceToNow(comment.timestamp, { addSuffix: true })}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2025-04-15 16:28:03 +05:30
|
|
|
</div>
|
2025-04-24 14:31:00 +05:30
|
|
|
<p className="text-sm break-words">{comment.content}</p>
|
2025-06-06 16:42:00 +05:30
|
|
|
{isCellAdmin && !comment.moderated && (
|
|
|
|
|
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerateComment(comment.id)}>
|
|
|
|
|
Moderate
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-06-06 16:45:14 +05:30
|
|
|
{isCellAdmin && comment.authorAddress !== cell.signature && (
|
|
|
|
|
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerateUser(comment.authorAddress)}>
|
|
|
|
|
Moderate User
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-06-06 16:42:00 +05:30
|
|
|
{comment.moderated && (
|
|
|
|
|
<span className="ml-2 text-xs text-red-500">[Moderated]</span>
|
|
|
|
|
)}
|
2025-04-15 16:28:03 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default PostDetail;
|