mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
usePost,
|
|
usePostComments,
|
|
useForumActions,
|
|
usePermissions,
|
|
useUserVotes,
|
|
usePostBookmark,
|
|
} from '@/hooks';
|
|
import { Button } from '@/components/ui/button';
|
|
//
|
|
import ResizableTextarea from '@/components/ui/resizable-textarea';
|
|
import {
|
|
ArrowLeft,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Clock,
|
|
MessageCircle,
|
|
Send,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
import { RelevanceIndicator } from './ui/relevance-indicator';
|
|
import { AuthorDisplay } from './ui/author-display';
|
|
import { BookmarkButton } from './ui/bookmark-button';
|
|
import { LinkRenderer } from './ui/link-renderer';
|
|
import CommentCard from './CommentCard';
|
|
import { usePending, usePendingVote } from '@/hooks/usePending';
|
|
import { ShareButton } from './ui/ShareButton';
|
|
|
|
const PostDetail = () => {
|
|
const { postId } = useParams<{ postId: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
// ✅ Use reactive hooks for data and actions
|
|
const post = usePost(postId);
|
|
const comments = usePostComments(postId);
|
|
const {
|
|
createComment,
|
|
votePost,
|
|
moderateComment,
|
|
unmoderateComment,
|
|
moderateUser,
|
|
isCreatingComment,
|
|
isVoting,
|
|
} = useForumActions();
|
|
const { canVote, canComment, canModerate } = usePermissions();
|
|
const userVotes = useUserVotes();
|
|
const {
|
|
isBookmarked,
|
|
loading: bookmarkLoading,
|
|
toggleBookmark,
|
|
} = usePostBookmark(post, post?.cellId);
|
|
|
|
// ✅ Move ALL hook calls to the top, before any conditional logic
|
|
const postPending = usePending(post?.id);
|
|
const postVotePending = usePendingVote(post?.id);
|
|
|
|
const [newComment, setNewComment] = useState('');
|
|
|
|
if (!postId) return <div>Invalid post ID</div>;
|
|
|
|
// ✅ Loading state handled by hook
|
|
if (comments.isLoading) {
|
|
return (
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!post) {
|
|
return (
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ✅ All data comes pre-computed from hooks
|
|
const { cell } = post;
|
|
const visibleComments = comments.comments; // Already filtered by hook
|
|
|
|
const handleCreateComment = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newComment.trim()) return;
|
|
|
|
// ✅ All validation handled in hook
|
|
const result = await createComment(postId, newComment);
|
|
if (result) {
|
|
setNewComment('');
|
|
}
|
|
};
|
|
|
|
// Handle keyboard shortcuts
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (!isCreatingComment && newComment.trim()) {
|
|
handleCreateComment(e as React.FormEvent);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleVotePost = async (isUpvote: boolean) => {
|
|
// ✅ Permission checking handled in hook
|
|
await votePost(post.id, isUpvote);
|
|
};
|
|
|
|
const handleBookmark = async (e?: React.MouseEvent) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
await toggleBookmark();
|
|
};
|
|
|
|
// ✅ Get vote status from hooks
|
|
const postVoteType = userVotes.getPostVoteType(post.id);
|
|
const isPostUpvoted = postVoteType === 'upvote';
|
|
const isPostDownvoted = postVoteType === 'downvote';
|
|
|
|
const handleModerateComment = async (commentId: string) => {
|
|
const reason =
|
|
window.prompt('Enter a reason for moderation (optional):') || undefined;
|
|
if (!cell) return;
|
|
// ✅ All validation handled in hook
|
|
await moderateComment(cell.id, commentId, reason);
|
|
};
|
|
|
|
const handleUnmoderateComment = async (commentId: string) => {
|
|
const reason =
|
|
window.prompt('Optional note for unmoderation?') || undefined;
|
|
if (!cell) return;
|
|
await unmoderateComment(cell.id, commentId, reason);
|
|
};
|
|
|
|
const handleModerateUser = async (userAddress: string) => {
|
|
const reason =
|
|
window.prompt('Reason for moderating this user? (optional)') || undefined;
|
|
if (!cell) return;
|
|
// ✅ All validation handled in hook
|
|
await moderateUser(cell.id, userAddress, reason);
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6">
|
|
<div className="mb-6">
|
|
<Button
|
|
onClick={() => navigate(`/cell/${post.cellId}`)}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mb-4"
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
|
Back to /{cell?.name || 'cell'}/
|
|
</Button>
|
|
|
|
<div className="border border-muted rounded-sm p-3 mb-6">
|
|
<div className="flex gap-4">
|
|
<div className="flex flex-col items-center">
|
|
<button
|
|
className={`p-1 rounded-sm hover:bg-muted/50 ${
|
|
isPostUpvoted ? 'text-primary' : ''
|
|
}`}
|
|
onClick={() => handleVotePost(true)}
|
|
disabled={!canVote || isVoting}
|
|
title={
|
|
canVote ? 'Upvote post' : 'Connect wallet and verify to vote'
|
|
}
|
|
>
|
|
<ArrowUp className="w-4 h-4" />
|
|
</button>
|
|
<span className="text-sm font-bold">{post.voteScore}</span>
|
|
<button
|
|
className={`p-1 rounded-sm hover:bg-muted/50 ${
|
|
isPostDownvoted ? 'text-primary' : ''
|
|
}`}
|
|
onClick={() => handleVotePost(false)}
|
|
disabled={!canVote || isVoting}
|
|
title={
|
|
canVote
|
|
? 'Downvote post'
|
|
: 'Connect wallet and verify to vote'
|
|
}
|
|
>
|
|
<ArrowDown className="w-4 h-4" />
|
|
</button>
|
|
{postVotePending.isPending && (
|
|
<span className="mt-1 text-[10px] text-yellow-500">
|
|
syncing…
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
|
<span className="font-medium text-primary">
|
|
r/{cell?.name || 'unknown'}
|
|
</span>
|
|
<span>•</span>
|
|
<span>Posted by u/</span>
|
|
<AuthorDisplay
|
|
address={post.author}
|
|
className="text-sm"
|
|
showBadge={false}
|
|
/>
|
|
<span>•</span>
|
|
<Clock className="w-3 h-3" />
|
|
<span>
|
|
{formatDistanceToNow(new Date(post.timestamp), {
|
|
addSuffix: true,
|
|
})}
|
|
</span>
|
|
{post.relevanceScore !== undefined && (
|
|
<>
|
|
<span>•</span>
|
|
<RelevanceIndicator
|
|
score={post.relevanceScore}
|
|
details={post.relevanceDetails}
|
|
type="post"
|
|
className="text-sm"
|
|
showTooltip={true}
|
|
/>
|
|
</>
|
|
)}
|
|
{postPending.isPending && (
|
|
<>
|
|
<span>•</span>
|
|
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
|
syncing…
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-start justify-between mb-3">
|
|
<h1 className="text-2xl font-bold flex-1">{post.title}</h1>
|
|
<BookmarkButton
|
|
isBookmarked={isBookmarked}
|
|
loading={bookmarkLoading}
|
|
onClick={handleBookmark}
|
|
size="lg"
|
|
variant="ghost"
|
|
showText={true}
|
|
/>
|
|
<ShareButton
|
|
size="lg"
|
|
url={`${window.location.origin}/post/${post.id}`}
|
|
title={post.title}
|
|
/>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap break-words">
|
|
<LinkRenderer text={post.content} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comment Form */}
|
|
{canComment && (
|
|
<div className="mb-8">
|
|
<form onSubmit={handleCreateComment} onKeyDown={handleKeyDown}>
|
|
<h2 className="text-sm font-bold mb-2 flex items-center gap-1">
|
|
<MessageCircle className="w-4 h-4" />
|
|
Add a comment
|
|
</h2>
|
|
<ResizableTextarea
|
|
placeholder="What are your thoughts?"
|
|
value={newComment}
|
|
onChange={e => setNewComment(e.target.value)}
|
|
className="bg-cyber-muted/50 border-cyber-muted"
|
|
disabled={isCreatingComment}
|
|
minHeight={100}
|
|
initialHeight={140}
|
|
maxHeight={600}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="submit"
|
|
disabled={!canComment || isCreatingComment}
|
|
className="bg-cyber-accent hover:bg-cyber-accent/80"
|
|
>
|
|
{isCreatingComment ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Posting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send className="w-4 h-4 mr-2" />
|
|
Post Comment
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{!canComment && (
|
|
<div className="mb-6 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center">
|
|
<p className="text-sm mb-3">
|
|
Connect wallet and verify Ordinal ownership to comment
|
|
</p>
|
|
<Button asChild size="sm">
|
|
<Link to="/">Connect Wallet</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Comments */}
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-bold flex items-center gap-2">
|
|
<MessageCircle className="w-5 h-5" />
|
|
Comments ({visibleComments.length})
|
|
</h2>
|
|
|
|
{visibleComments.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<MessageCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
|
<h3 className="text-lg font-bold mb-2">No comments yet</h3>
|
|
<p className="text-muted-foreground">
|
|
{canComment
|
|
? 'Be the first to share your thoughts!'
|
|
: 'Connect your wallet to join the conversation.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
visibleComments.map(comment => (
|
|
<CommentCard
|
|
key={comment.id}
|
|
comment={comment}
|
|
postId={postId}
|
|
cellId={cell?.id}
|
|
canModerate={canModerate(cell?.id || '')}
|
|
onModerateComment={handleModerateComment}
|
|
onUnmoderateComment={handleUnmoderateComment}
|
|
onModerateUser={handleModerateUser}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PostDetail;
|