{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "comment-thread-shadcnui", "type": "registry:component", "title": "Comment Thread", "description": "Nested comment thread with rich interactions and animations", "registryDependencies": [ "button" ], "dependencies": [ "framer-motion", "react" ], "files": [ { "path": "@uitripled/react-shadcn/src/components/components/comments/comment-thread.tsx", "content": "\"use client\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport {\n CornerDownRight,\n Heart,\n Image as ImageIcon,\n MessageCircle,\n MoreHorizontal,\n Paperclip,\n Send,\n Share2,\n Smile,\n} from \"lucide-react\";\nimport { useEffect, useRef, useState } from \"react\";\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\ninterface User {\n name: string;\n avatar: string;\n role?: string;\n}\n\ninterface Comment {\n id: string;\n user: User;\n content: string;\n timestamp: string;\n likes: number;\n replies?: Comment[];\n isLiked?: boolean;\n}\n\n// ============================================================================\n// DUMMY DATA\n// ============================================================================\n\nconst INITIAL_COMMENTS: Comment[] = [\n {\n id: \"1\",\n user: {\n name: \"Alex Morgan\",\n avatar: \"https://i.pravatar.cc/150?u=alex\",\n role: \"Product Designer\",\n },\n content:\n \"The new glassmorphism trend is really interesting. I love how it adds depth without cluttering the interface. Has anyone tried implementing this with pure CSS vs using backdrop-filter?\",\n timestamp: \"2h ago\",\n likes: 24,\n isLiked: true,\n replies: [\n {\n id: \"1-1\",\n user: {\n name: \"Sarah Chen\",\n avatar: \"https://i.pravatar.cc/150?u=sarah\",\n role: \"Frontend Dev\",\n },\n content:\n \"I've been using backdrop-filter extensively. It's much more performant now across modern browsers. The only catch is Firefox sometimes needs a fallback.\",\n timestamp: \"1h ago\",\n likes: 12,\n isLiked: false,\n replies: [],\n },\n {\n id: \"1-2\",\n user: {\n name: \"Mike Ross\",\n avatar: \"https://i.pravatar.cc/150?u=mike\",\n },\n content:\n \"Agreed! It gives such a premium feel. I usually pair it with subtle noise textures to avoid banding.\",\n timestamp: \"45m ago\",\n likes: 8,\n isLiked: false,\n replies: [],\n },\n ],\n },\n {\n id: \"2\",\n user: {\n name: \"Emily Watson\",\n avatar: \"https://i.pravatar.cc/150?u=emily\",\n role: \"UX Researcher\",\n },\n content:\n \"Great article! I'm curious about the accessibility implications of these high-contrast dark modes. Do we have any data on user preference?\",\n timestamp: \"3h ago\",\n likes: 45,\n isLiked: false,\n replies: [],\n },\n];\n\n// ============================================================================\n// COMPONENTS\n// ============================================================================\n\nfunction CommentInput({\n placeholder = \"What are your thoughts?\",\n onSubmit,\n onCancel,\n autoFocus = false,\n className,\n inputId,\n labelId,\n}: {\n placeholder?: string;\n onSubmit: (content: string) => void;\n onCancel?: () => void;\n autoFocus?: boolean;\n className?: string;\n inputId?: string;\n labelId?: string;\n}) {\n const [content, setContent] = useState(\"\");\n const [isFocused, setIsFocused] = useState(autoFocus);\n const textareaRef = useRef(null);\n\n useEffect(() => {\n if (autoFocus && textareaRef.current) {\n textareaRef.current.focus();\n }\n }, [autoFocus]);\n\n const handleSubmit = () => {\n if (!content.trim()) return;\n onSubmit(content);\n setContent(\"\");\n setIsFocused(false);\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n e.preventDefault();\n handleSubmit();\n } else if (e.key === \"Escape\" && onCancel) {\n e.preventDefault();\n onCancel();\n }\n };\n\n const uniqueId =\n inputId || `comment-input-${Math.random().toString(36).substr(2, 9)}`;\n const uniqueLabelId =\n labelId || `comment-label-${Math.random().toString(36).substr(2, 9)}`;\n\n return (\n \n
\n
\n \n \n YO\n \n
\n \n setContent(e.target.value)}\n onFocus={() => setIsFocused(true)}\n onKeyDown={handleKeyDown}\n autoFocus={autoFocus}\n aria-label={placeholder}\n aria-describedby={uniqueLabelId}\n className=\"min-h-[60px] border-none bg-transparent p-0 resize-none focus-visible:ring-0 placeholder:text-muted-foreground/70 text-sm\"\n />\n
\n
\n
\n\n {/* Toolbar */}\n \n \n \n \n \n \n \n \n \n \n \n \n
\n {onCancel && (\n \n Cancel\n \n )}\n \n {onCancel ? \"Reply\" : \"Post\"}\n \n \n
\n \n \n );\n}\n\nfunction CommentItem({\n comment,\n isReply = false,\n activeReplyId,\n setActiveReplyId,\n onAddReply,\n}: {\n comment: Comment;\n isReply?: boolean;\n activeReplyId: string | null;\n setActiveReplyId: (id: string | null) => void;\n onAddReply: (parentId: string, content: string) => void;\n}) {\n const [isLiked, setIsLiked] = useState(comment.isLiked);\n const [likesCount, setLikesCount] = useState(comment.likes);\n const [isExpanded, setIsExpanded] = useState(true);\n const replyInputRef = useRef(null);\n\n const handleLike = () => {\n if (isLiked) {\n setLikesCount((prev) => prev - 1);\n } else {\n setLikesCount((prev) => prev + 1);\n }\n setIsLiked(!isLiked);\n };\n\n const isReplying = activeReplyId === comment.id;\n\n useEffect(() => {\n if (isReplying && replyInputRef.current) {\n const textarea = replyInputRef.current.querySelector(\"textarea\");\n if (textarea) {\n setTimeout(() => textarea.focus(), 100);\n }\n }\n }, [isReplying]);\n\n const commentId = `comment-${comment.id}`;\n const repliesId = `replies-${comment.id}`;\n\n return (\n \n
\n \n \n \n {comment.user.name[0]}\n \n \n\n
\n {/* Header */}\n
\n
\n \n {comment.user.name}\n \n {comment.user.role && (\n \n {comment.user.role}\n \n )}\n \n • {comment.timestamp}\n \n
\n \n \n \n \n \n \n \n Report\n Copy Link\n \n \n
\n\n {/* Content */}\n

\n {comment.content}\n

\n\n {/* Actions */}\n \n \n \n \n {likesCount}\n \n \n setActiveReplyId(isReplying ? null : comment.id)}\n type=\"button\"\n className={cn(\n \"flex items-center gap-1.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded\",\n isReplying\n ? \"text-primary\"\n : \"text-muted-foreground hover:text-foreground\"\n )}\n aria-label={\n isReplying ? \"Cancel reply\" : `Reply to ${comment.user.name}`\n }\n aria-expanded={isReplying}\n aria-controls={\n isReplying ? `reply-input-${comment.id}` : undefined\n }\n >\n \n Reply\n \n \n \n Share\n \n \n\n {/* Inline Reply Input */}\n \n {isReplying && (\n \n onAddReply(comment.id, content)}\n onCancel={() => setActiveReplyId(null)}\n />\n \n )}\n \n
\n
\n\n {/* Nested Replies */}\n {comment.replies && comment.replies.length > 0 && (\n \n {isExpanded ? (\n \n {comment.replies.map((reply) => (\n \n ))}\n \n ) : null}\n\n setIsExpanded(!isExpanded)}\n type=\"button\"\n className=\"ml-12 text-xs font-medium text-primary hover:underline flex items-center gap-1 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded\"\n aria-label={\n isExpanded\n ? \"Hide replies\"\n : `Show ${comment.replies.length} replies`\n }\n aria-expanded={isExpanded}\n aria-controls={repliesId}\n >\n {isExpanded ? (\n \n ) : (\n \n )}\n {isExpanded\n ? \"Hide replies\"\n : `Show ${comment.replies.length} ${comment.replies.length === 1 ? \"reply\" : \"replies\"}`}\n \n \n )}\n \n );\n}\n\nexport function CommentThread() {\n const [comments, setComments] = useState(INITIAL_COMMENTS);\n const [activeReplyId, setActiveReplyId] = useState(null);\n const [announcement, setAnnouncement] = useState(\"\");\n\n // Recursive function to add reply\n const addReplyToTree = (\n comments: Comment[],\n parentId: string,\n newReply: Comment\n ): Comment[] => {\n return comments.map((comment) => {\n if (comment.id === parentId) {\n return {\n ...comment,\n replies: [...(comment.replies || []), newReply],\n };\n } else if (comment.replies && comment.replies.length > 0) {\n return {\n ...comment,\n replies: addReplyToTree(comment.replies, parentId, newReply),\n };\n }\n return comment;\n });\n };\n\n const handleAddComment = (content: string) => {\n const newComment: Comment = {\n id: Date.now().toString(),\n user: {\n name: \"You\",\n avatar: \"https://github.com/shadcn.png\",\n role: \"Guest\",\n },\n content,\n timestamp: \"Just now\",\n likes: 0,\n replies: [],\n };\n\n setComments([newComment, ...comments]);\n setAnnouncement(\"Comment posted successfully\");\n setTimeout(() => setAnnouncement(\"\"), 1000);\n };\n\n const handleAddReply = (parentId: string, content: string) => {\n const newReply: Comment = {\n id: Date.now().toString(),\n user: {\n name: \"You\",\n avatar: \"https://github.com/shadcn.png\",\n role: \"Guest\",\n },\n content,\n timestamp: \"Just now\",\n likes: 0,\n replies: [],\n };\n\n setComments((prevComments) =>\n addReplyToTree(prevComments, parentId, newReply)\n );\n setActiveReplyId(null);\n setAnnouncement(\"Reply posted successfully\");\n setTimeout(() => setAnnouncement(\"\"), 1000);\n };\n\n return (\n \n {/* Screen reader announcements */}\n \n {announcement}\n \n\n {/* Header */}\n
\n \n Comments\n \n , {comments.length} {comments.length === 1 ? \"comment\" : \"comments\"}\n \n \n \n \n Newest\n \n \n Top\n \n \n
\n\n {/* Main Input Area */}\n
\n

\n Write a new comment\n

\n \n
\n\n {/* Comments List */}\n
\n
\n \n {comments.map((comment) => (\n \n ))}\n \n
\n
\n \n );\n}\n", "type": "registry:component", "target": "components/uitripled/comment-thread-shadcnui.tsx" } ] }