Spaces:
Configuration error
Configuration error
| "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> | |
| ); | |
| } | |