open-prompt / src /components /comments /comments.tsx
GitHub Action
Automated sync to Hugging Face
bcce530
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MessageSquare,
Heart,
Reply,
MoreHorizontal,
Send,
User,
ChevronDown,
ChevronUp,
Flag,
Trash2,
Edit
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatDistanceToNow } from "date-fns";
interface CommentUser {
id: string;
name: string;
username: string;
image?: string;
rank?: string;
}
interface Comment {
id: string;
content: string;
user: CommentUser;
likes: number;
liked?: boolean;
createdAt: Date;
replies?: Comment[];
}
interface CommentsProps {
promptId: string;
comments?: Comment[];
currentUser?: CommentUser | null;
}
// Sample comments for demo
const SAMPLE_COMMENTS: Comment[] = [
{
id: "1",
content: "This prompt is amazing! I've been using it for all my blog posts and the quality is incredible. Definitely recommend tweaking the tone variable for different audiences.",
user: {
id: "u1",
name: "Sarah Chen",
username: "sarahchen",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=sarah",
rank: "gold"
},
likes: 24,
liked: false,
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
replies: [
{
id: "1-1",
content: "Thanks for the tip! What tone setting works best for technical documentation?",
user: {
id: "u2",
name: "Mike Johnson",
username: "mikej",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=mike",
rank: "silver"
},
likes: 8,
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000),
},
{
id: "1-2",
content: "For technical docs I use 'professional and precise'. Works great!",
user: {
id: "u1",
name: "Sarah Chen",
username: "sarahchen",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=sarah",
rank: "gold"
},
likes: 12,
createdAt: new Date(Date.now() - 30 * 60 * 1000),
}
]
},
{
id: "2",
content: "I remixed this with a few tweaks for SEO optimization. Check out my version if you're looking for better search rankings!",
user: {
id: "u3",
name: "Alex Rivera",
username: "alexr",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=alex",
rank: "verified"
},
likes: 45,
liked: true,
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
},
{
id: "3",
content: "Does this work well with Claude or is it optimized specifically for GPT-4?",
user: {
id: "u4",
name: "Jordan Lee",
username: "jordanl",
image: "https://api.dicebear.com/7.x/avataaars/svg?seed=jordan",
},
likes: 6,
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
},
];
function RankBadge({ rank }: { rank?: string }) {
if (!rank) return null;
const colors: Record<string, string> = {
bronze: "bg-orange-900/50 text-orange-300 border-orange-700",
silver: "bg-gray-700/50 text-gray-300 border-gray-600",
gold: "bg-yellow-900/50 text-yellow-300 border-yellow-700",
verified: "bg-blue-900/50 text-blue-300 border-blue-700",
};
return (
<Badge variant="outline" className={`text-xs ${colors[rank] || ""}`}>
{rank === "verified" ? "✓ Verified" : rank.charAt(0).toUpperCase() + rank.slice(1)}
</Badge>
);
}
function CommentItem({
comment,
isReply = false,
onReply,
onLike,
currentUser
}: {
comment: Comment;
isReply?: boolean;
onReply: (commentId: string) => void;
onLike: (commentId: string) => void;
currentUser?: CommentUser | null;
}) {
const [showReplies, setShowReplies] = useState(true);
const [isLiked, setIsLiked] = useState(comment.liked || false);
const [likesCount, setLikesCount] = useState(comment.likes);
const handleLike = () => {
if (isLiked) {
setLikesCount(prev => prev - 1);
} else {
setLikesCount(prev => prev + 1);
}
setIsLiked(!isLiked);
onLike(comment.id);
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`${isReply ? "ml-12 border-l-2 border-muted pl-4" : ""}`}
>
<div className="flex gap-3 py-4">
<Avatar className="h-10 w-10">
<AvatarImage src={comment.user.image} />
<AvatarFallback>
<User className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{comment.user.name}</span>
<span className="text-sm text-muted-foreground">@{comment.user.username}</span>
<RankBadge rank={comment.user.rank} />
<span className="text-sm text-muted-foreground">
· {formatDistanceToNow(comment.createdAt, { addSuffix: true })}
</span>
</div>
<p className="mt-1 text-sm leading-relaxed">{comment.content}</p>
<div className="flex items-center gap-4 mt-2">
<Button
variant="ghost"
size="sm"
className={`h-8 px-2 ${isLiked ? "text-pink-500" : "text-muted-foreground"}`}
onClick={handleLike}
>
<Heart className={`h-4 w-4 mr-1 ${isLiked ? "fill-current" : ""}`} />
{likesCount}
</Button>
{!isReply && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground"
onClick={() => onReply(comment.id)}
>
<Reply className="h-4 w-4 mr-1" />
Reply
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{currentUser?.id === comment.user.id && (
<>
<DropdownMenuItem>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="ml-4">
<Button
variant="ghost"
size="sm"
className="text-primary h-8 mb-2"
onClick={() => setShowReplies(!showReplies)}
>
{showReplies ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Hide {comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
Show {comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
</>
)}
</Button>
<AnimatePresence>
{showReplies && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
isReply
onReply={onReply}
onLike={onLike}
currentUser={currentUser}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)}
</motion.div>
);
}
export function Comments({ promptId, comments = SAMPLE_COMMENTS, currentUser }: CommentsProps) {
const [newComment, setNewComment] = useState("");
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [allComments, setAllComments] = useState(comments);
const handleSubmit = () => {
if (!newComment.trim()) return;
// In real app, this would be an API call
const comment: Comment = {
id: Date.now().toString(),
content: newComment,
user: currentUser || {
id: "guest",
name: "Guest User",
username: "guest"
},
likes: 0,
createdAt: new Date(),
};
if (replyingTo) {
setAllComments(prev =>
prev.map(c => {
if (c.id === replyingTo) {
return {
...c,
replies: [...(c.replies || []), comment]
};
}
return c;
})
);
setReplyingTo(null);
} else {
setAllComments(prev => [comment, ...prev]);
}
setNewComment("");
};
const handleReply = (commentId: string) => {
setReplyingTo(commentId);
// Focus the textarea
document.getElementById("comment-input")?.focus();
};
const handleLike = (commentId: string) => {
// In real app, this would be an API call
console.log("Liked comment:", commentId);
};
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<h3 className="text-lg font-semibold">
Comments ({allComments.length})
</h3>
</div>
{/* Comment Input */}
<div className="space-y-3">
{replyingTo && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-2 rounded-md">
<Reply className="h-4 w-4" />
<span>Replying to comment</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 ml-auto"
onClick={() => setReplyingTo(null)}
>
Cancel
</Button>
</div>
)}
<div className="flex gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={currentUser?.image} />
<AvatarFallback>
<User className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<Textarea
id="comment-input"
placeholder={replyingTo ? "Write a reply..." : "Add a comment..."}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={!newComment.trim()}
className="bg-linear-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
>
<Send className="h-4 w-4 mr-2" />
{replyingTo ? "Reply" : "Comment"}
</Button>
</div>
</div>
</div>
</div>
{/* Comments List */}
<div className="divide-y">
{allComments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
onReply={handleReply}
onLike={handleLike}
currentUser={currentUser}
/>
))}
</div>
{allComments.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No comments yet. Be the first to comment!</p>
</div>
)}
</div>
);
}