{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "discussion-post-card", "title": "Discussion Post Card", "description": "Discussion post card with voting, bookmarking, rich-text content, and a responsive replies drawer.", "dependencies": [ "@radix-ui/react-dialog", "lucide-react", "dompurify", "@tiptap/react", "@tiptap/starter-kit" ], "files": [ { "path": "registry/components/discussion-post-card/index.ts", "content": "export { DiscussionPostCard } from \"./discussion-post-card\"\nexport { DiscussionPostCardComposer } from \"./discussion-post-card-composer\"\nexport { RichTextEditor } from \"./rich-text-editor\"\nexport { RichTextContent } from \"./rich-text-content\"\nexport type { DiscussionPostCardProps, DiscussionReply, DrawerDirection } from \"./types\"\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/discussion-post-card.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { DiscussionPostCardDrawer } from \"./discussion-post-card-drawer\"\nimport { DiscussionPostCardPreview } from \"./discussion-post-card-preview\"\nimport type {\n DiscussionPostCardProps,\n DrawerDirection,\n VoteDirection,\n} from \"./types\"\n\nfunction useResolvedDirection(direction: DrawerDirection) {\n const [isDesktop, setIsDesktop] = React.useState(false)\n\n React.useEffect(() => {\n const mediaQuery = window.matchMedia(\"(min-width: 768px)\")\n const sync = () => setIsDesktop(mediaQuery.matches)\n sync()\n mediaQuery.addEventListener(\"change\", sync)\n return () => mediaQuery.removeEventListener(\"change\", sync)\n }, [])\n\n return direction === \"auto\" ? (isDesktop ? \"right\" : \"bottom\") : direction\n}\n\nexport function DiscussionPostCard({\n profileImage,\n name,\n createdAt,\n content,\n currentUpvoteCount,\n currentDownvoteCount,\n hideDownvoteCount = false,\n onUpvoteClick,\n onDownvoteClick,\n initialBookmarked = false,\n replies = [],\n replyText,\n onReplyTextChange,\n onReplySubmit,\n replyPlaceholder,\n drawerDirection = \"auto\",\n drawerBottomInsetClassName,\n drawerBodyClassName,\n drawerPinFooter = true,\n drawerFooterClassName,\n}: DiscussionPostCardProps) {\n const [open, setOpen] = React.useState(false)\n const [isBookmarked, setIsBookmarked] = React.useState(initialBookmarked)\n const [voteDirection, setVoteDirection] = React.useState(null)\n const [voteCounts, setVoteCounts] = React.useState({\n upvotes: currentUpvoteCount,\n downvotes: currentDownvoteCount,\n })\n const resolvedDirection = useResolvedDirection(drawerDirection)\n\n React.useEffect(() => {\n setVoteCounts({\n upvotes: currentUpvoteCount,\n downvotes: currentDownvoteCount,\n })\n }, [currentUpvoteCount, currentDownvoteCount])\n\n const handleUpvoteClick = () => {\n setVoteCounts((current) => {\n if (voteDirection === \"up\") {\n setVoteDirection(null)\n return {\n upvotes: Math.max(current.upvotes - 1, 0),\n downvotes: current.downvotes,\n }\n }\n\n if (voteDirection === \"down\") {\n setVoteDirection(\"up\")\n return {\n upvotes: current.upvotes + 1,\n downvotes: Math.max(current.downvotes - 1, 0),\n }\n }\n\n setVoteDirection(\"up\")\n return {\n upvotes: current.upvotes + 1,\n downvotes: current.downvotes,\n }\n })\n onUpvoteClick()\n }\n\n const handleDownvoteClick = () => {\n setVoteCounts((current) => {\n if (voteDirection === \"down\") {\n setVoteDirection(null)\n return {\n upvotes: current.upvotes,\n downvotes: Math.max(current.downvotes - 1, 0),\n }\n }\n\n if (voteDirection === \"up\") {\n setVoteDirection(\"down\")\n return {\n upvotes: Math.max(current.upvotes - 1, 0),\n downvotes: current.downvotes + 1,\n }\n }\n\n setVoteDirection(\"down\")\n return {\n upvotes: current.upvotes,\n downvotes: current.downvotes + 1,\n }\n })\n onDownvoteClick()\n }\n\n return (\n <>\n setIsBookmarked((current) => !current)}\n onUpvoteClick={handleUpvoteClick}\n onDownvoteClick={handleDownvoteClick}\n onReplyClick={() => setOpen(true)}\n replyCount={replies.length}\n />\n\n setIsBookmarked((current) => !current)}\n open={open}\n onOpenChange={setOpen}\n resolvedDirection={resolvedDirection}\n drawerBottomInsetClassName={drawerBottomInsetClassName}\n drawerBodyClassName={drawerBodyClassName}\n drawerPinFooter={drawerPinFooter}\n drawerFooterClassName={drawerFooterClassName}\n />\n \n )\n}\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/discussion-post-card-preview.tsx", "content": "import {\n ArrowBigDown,\n ArrowBigUp,\n Bookmark,\n MessageCircle,\n} from \"lucide-react\";\nimport type { ReactNode } from \"react\";\n\nimport { RichTextContent } from \"./rich-text-content\";\nimport type { DiscussionPostCardProps, VoteDirection } from \"./types\";\n\ntype DiscussionPostCardPreviewProps = Pick<\n DiscussionPostCardProps,\n \"profileImage\" | \"name\" | \"createdAt\" | \"content\" | \"hideDownvoteCount\"\n> & {\n currentUpvoteCount: number;\n currentDownvoteCount: number;\n isBookmarked?: boolean;\n onBookmarkClick?: () => void;\n onUpvoteClick: () => void;\n onDownvoteClick: () => void;\n onReplyClick?: () => void;\n replyCount: number;\n showReplyAction?: boolean;\n showBookmarkAction?: boolean;\n showDivider?: boolean;\n voteDirection?: VoteDirection;\n};\n\ntype CountActionButtonProps = {\n icon: ReactNode;\n value: number;\n onClick: () => void;\n srLabel: string;\n hideValue?: boolean;\n isActive?: boolean;\n};\n\nfunction CountActionButton({\n icon,\n value,\n onClick,\n srLabel,\n hideValue = false,\n isActive = false,\n}: CountActionButtonProps) {\n return (\n \n {icon}\n {!hideValue ? (\n \n {value}\n \n ) : null}\n {srLabel}\n \n );\n}\n\nexport function DiscussionPostCardPreview({\n profileImage,\n name,\n createdAt,\n content,\n currentUpvoteCount,\n currentDownvoteCount,\n hideDownvoteCount = false,\n isBookmarked = false,\n onBookmarkClick,\n onUpvoteClick,\n onDownvoteClick,\n onReplyClick,\n replyCount,\n showReplyAction = true,\n showBookmarkAction = true,\n showDivider = true,\n voteDirection = null,\n}: DiscussionPostCardPreviewProps) {\n return (\n
\n
\n
\n \n
\n

\n {name}\n

\n

\n {createdAt}\n

\n
\n
\n\n {showBookmarkAction ? (\n {})}\n className=\"cursor-pointer\"\n >\n \n Bookmark discussion\n \n ) : null}\n
\n\n \n\n {showDivider ?
: null}\n
\n
\n \n }\n value={currentUpvoteCount}\n onClick={onUpvoteClick}\n srLabel=\"Upvote discussion\"\n isActive={voteDirection === \"up\"}\n />\n \n }\n value={currentDownvoteCount}\n onClick={onDownvoteClick}\n srLabel=\"Downvote discussion\"\n hideValue={hideDownvoteCount}\n isActive={voteDirection === \"down\"}\n />\n {showReplyAction ? (\n }\n value={replyCount}\n onClick={onReplyClick ?? (() => {})}\n srLabel=\"Open replies\"\n />\n ) : null}\n
\n
\n
\n );\n}\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/discussion-post-card-drawer.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { X } from \"lucide-react\";\n\nimport { DiscussionPostCardPreview } from \"./discussion-post-card-preview\";\nimport { DiscussionPostCardComposer } from \"./discussion-post-card-composer\";\nimport type {\n DiscussionPostCardProps,\n DrawerDirection,\n VoteDirection,\n} from \"./types\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype DiscussionPostCardDrawerProps = Pick<\n DiscussionPostCardProps,\n | \"profileImage\"\n | \"name\"\n | \"createdAt\"\n | \"content\"\n | \"currentUpvoteCount\"\n | \"currentDownvoteCount\"\n | \"voteDirection\"\n | \"hideDownvoteCount\"\n | \"onUpvoteClick\"\n | \"onDownvoteClick\"\n | \"replies\"\n | \"replyText\"\n | \"onReplyTextChange\"\n | \"onReplySubmit\"\n | \"replyPlaceholder\"\n | \"drawerBottomInsetClassName\"\n | \"drawerBodyClassName\"\n | \"drawerPinFooter\"\n | \"drawerFooterClassName\"\n> & {\n isBookmarked: boolean;\n onBookmarkClick: () => void;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n resolvedDirection: Exclude;\n};\n\nexport function DiscussionPostCardDrawer({\n profileImage,\n name,\n createdAt,\n content,\n currentUpvoteCount,\n currentDownvoteCount,\n voteDirection = null,\n hideDownvoteCount = false,\n onUpvoteClick,\n onDownvoteClick,\n replies = [],\n replyText,\n onReplyTextChange,\n onReplySubmit,\n replyPlaceholder,\n isBookmarked,\n onBookmarkClick,\n open,\n onOpenChange,\n resolvedDirection,\n drawerBottomInsetClassName,\n drawerBodyClassName,\n drawerPinFooter = true,\n drawerFooterClassName,\n}: DiscussionPostCardDrawerProps) {\n const [replyVotes, setReplyVotes] = React.useState<\n Record\n >({});\n\n React.useEffect(() => {\n setReplyVotes(\n replies.reduce<\n Record\n >(\n (accumulator, reply) => {\n accumulator[reply.id] = {\n upvotes: reply.currentUpvoteCount ?? 0,\n downvotes: reply.currentDownvoteCount ?? 0,\n direction: reply.voteDirection ?? null,\n };\n return accumulator;\n },\n {},\n ),\n );\n }, [replies]);\n\n const composerFooter = (\n \n \n \n );\n\n return (\n \n \n \n \n
\n \n Discussion Thread\n \n \n \n Close\n \n
\n\n \n \n\n
\n

\n Responses\n

\n {replies.length ? (\n
\n {replies.map((reply) => (\n {\n setReplyVotes((current) => {\n const currentState = current[reply.id] ?? {\n upvotes: 0,\n downvotes: 0,\n direction: null,\n };\n\n if (currentState.direction === \"up\") {\n return {\n ...current,\n [reply.id]: {\n ...currentState,\n upvotes: Math.max(currentState.upvotes - 1, 0),\n direction: null,\n },\n };\n }\n\n if (currentState.direction === \"down\") {\n return {\n ...current,\n [reply.id]: {\n upvotes: currentState.upvotes + 1,\n downvotes: Math.max(currentState.downvotes - 1, 0),\n direction: \"up\",\n },\n };\n }\n\n return {\n ...current,\n [reply.id]: {\n ...currentState,\n upvotes: currentState.upvotes + 1,\n direction: \"up\",\n },\n };\n });\n }}\n onDownvoteClick={() => {\n setReplyVotes((current) => {\n const currentState = current[reply.id] ?? {\n upvotes: 0,\n downvotes: 0,\n direction: null,\n };\n\n if (currentState.direction === \"down\") {\n return {\n ...current,\n [reply.id]: {\n ...currentState,\n downvotes: Math.max(currentState.downvotes - 1, 0),\n direction: null,\n },\n };\n }\n\n if (currentState.direction === \"up\") {\n return {\n ...current,\n [reply.id]: {\n upvotes: Math.max(currentState.upvotes - 1, 0),\n downvotes: currentState.downvotes + 1,\n direction: \"down\",\n },\n };\n }\n\n return {\n ...current,\n [reply.id]: {\n ...currentState,\n downvotes: currentState.downvotes + 1,\n direction: \"down\",\n },\n };\n });\n }}\n replyCount={0}\n showReplyAction={false}\n showBookmarkAction={false}\n showDivider={false}\n />\n ))}\n
\n ) : (\n
\n No replies yet. Start the discussion.\n
\n )}\n
\n\n {!drawerPinFooter ? composerFooter : null}\n \n\n {drawerPinFooter ? composerFooter : null}\n \n
\n
\n );\n}\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/discussion-post-card-composer.tsx", "content": "\"use client\";\n\nimport { SendHorizontal } from \"lucide-react\";\n\nimport { RichTextEditor } from \"./rich-text-editor\";\n\ntype DiscussionPostCardComposerProps = {\n profileImage?: string;\n replyText: string;\n onReplyTextChange: (value: string) => void;\n onReplySubmit?: () => void;\n placeholder?: string;\n showOuterRoundedBorder?: boolean;\n};\n\nexport function DiscussionPostCardComposer({\n profileImage,\n replyText,\n onReplyTextChange,\n onReplySubmit,\n placeholder = \"Write your reply...\",\n showOuterRoundedBorder = true,\n}: DiscussionPostCardComposerProps) {\n const hasReplyText =\n replyText\n .replace(/<[^>]*>/g, \"\")\n .replace(/ /g, \" \")\n .trim().length > 0;\n const isMultilineReply =\n //i.test(replyText) ||\n /<\\/p>\\s*

/i.test(replyText) ||\n replyText.includes(\"\\n\");\n\n return (\n \n \n

\n \n \n \n \n Send reply\n \n
\n \n );\n}\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/rich-text-editor.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport StarterKit from \"@tiptap/starter-kit\"\nimport { EditorContent, useEditor, useEditorState } from \"@tiptap/react\"\nimport {\n Bold,\n Italic,\n List,\n ListOrdered,\n} from \"lucide-react\"\n\ntype RichTextEditorProps = {\n value: string\n onChange: (value: string) => void\n placeholder?: string\n className?: string\n contentClassName?: string\n showToolbar?: boolean\n}\n\nexport function RichTextEditor({\n value,\n onChange,\n placeholder = \"Write here...\",\n className = \"\",\n contentClassName = \"\",\n showToolbar = true,\n}: RichTextEditorProps) {\n const contentBorderClass = showToolbar\n ? \"rounded-b-lg border border-t-0 border-[#E5E7EB]\"\n : \"rounded-lg border border-[#E5E7EB]\"\n\n const [isMounted, setIsMounted] = React.useState(false)\n\n React.useEffect(() => {\n setIsMounted(true)\n }, [])\n\n const editor = useEditor({\n extensions: [StarterKit],\n content: value || \"

\",\n editorProps: {\n attributes: {\n class:\n `min-h-24 ${contentBorderClass} px-3 py-2 text-[14px] leading-[22px] text-[#111928] outline-none [&_h1]:my-2 [&_h1]:text-[28px] [&_h1]:font-[700] [&_h1]:leading-[36px] [&_h2]:my-2 [&_h2]:text-[22px] [&_h2]:font-[600] [&_h2]:leading-[30px] [&_h3]:my-2 [&_h3]:text-[18px] [&_h3]:font-[600] [&_h3]:leading-[26px] [&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:border-l-2 [&_blockquote]:border-[#E5E7EB] [&_blockquote]:pl-3 ${contentClassName}`,\n },\n },\n onUpdate: ({ editor: tiptapEditor }) => {\n onChange(tiptapEditor.getHTML())\n },\n immediatelyRender: false,\n })\n\n React.useEffect(() => {\n if (!editor) return\n const current = editor.getHTML()\n if (current !== value) {\n editor.commands.setContent(value || \"

\", { emitUpdate: false })\n }\n }, [editor, value])\n\n const editorState = useEditorState({\n editor,\n selector: ({ editor }) => ({\n isBold: editor?.isActive(\"bold\") ?? false,\n isItalic: editor?.isActive(\"italic\") ?? false,\n isBulletList: editor?.isActive(\"bulletList\") ?? false,\n isOrderedList: editor?.isActive(\"orderedList\") ?? false,\n isEmpty: editor?.isEmpty ?? true,\n }),\n })\n\n const toolbarButtonClass = (isActive: boolean) =>\n `inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] transition-colors ${\n isActive\n ? \"bg-[#FDE8D7] text-[#B45309]\"\n : \"text-[#4B5563] hover:bg-[#F9FAFB]\"\n }`\n\n if (!isMounted || !editor) {\n return (\n onChange(event.target.value)}\n placeholder={placeholder}\n className={`min-h-24 w-full resize-y rounded-lg border border-[#E5E7EB] px-3 py-2 text-[14px] leading-[22px] text-[#111928] outline-none placeholder:text-[#9CA3AF] ${className}`}\n />\n )\n }\n\n return (\n
\n {showToolbar ? (\n
\n event.preventDefault()}\n onClick={() => editor?.chain().focus().toggleBold().run()}\n className={toolbarButtonClass(editorState?.isBold ?? false)}\n >\n \n \n event.preventDefault()}\n onClick={() => editor?.chain().focus().toggleItalic().run()}\n className={toolbarButtonClass(editorState?.isItalic ?? false)}\n >\n \n \n event.preventDefault()}\n onClick={() => editor?.chain().focus().toggleBulletList().run()}\n className={toolbarButtonClass(editorState?.isBulletList ?? false)}\n >\n \n \n event.preventDefault()}\n onClick={() => editor?.chain().focus().toggleOrderedList().run()}\n className={toolbarButtonClass(editorState?.isOrderedList ?? false)}\n >\n \n \n
\n ) : null}\n \n {editorState?.isEmpty ? (\n \n {placeholder}\n

\n ) : null}\n
\n )\n}\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/rich-text-content.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport createDOMPurify from \"dompurify\"\n\ntype RichTextContentProps = {\n html: string\n className?: string\n}\n\nexport function RichTextContent({ html, className = \"\" }: RichTextContentProps) {\n const sanitizedHtml = React.useMemo(() => {\n if (typeof window === \"undefined\") return html\n const purifier = createDOMPurify(window)\n return purifier.sanitize(html)\n }, [html])\n\n return (\n \n )\n}\n", "type": "registry:block" }, { "path": "registry/components/discussion-post-card/types.ts", "content": "export type DrawerDirection = \"right\" | \"bottom\" | \"auto\"\nexport type VoteDirection = \"up\" | \"down\" | null\n\nexport type DiscussionReply = {\n id: string\n profileImage?: string\n author: string\n createdAt: string\n content: string\n currentUpvoteCount?: number\n currentDownvoteCount?: number\n voteDirection?: VoteDirection\n}\n\nexport type DiscussionPostCardProps = {\n profileImage: string\n name: string\n createdAt: string\n content: string\n currentUpvoteCount: number\n currentDownvoteCount: number\n voteDirection?: VoteDirection\n hideDownvoteCount?: boolean\n onUpvoteClick: () => void\n onDownvoteClick: () => void\n initialBookmarked?: boolean\n replies?: Array\n replyText: string\n onReplyTextChange: (value: string) => void\n onReplySubmit?: () => void\n replyPlaceholder?: string\n drawerDirection?: DrawerDirection\n /**\n * Merged onto `Dialog.Content` — use on bottom sheets to clear a fixed bottom tab bar, e.g.\n * `pb-[calc(4.5rem+env(safe-area-inset-bottom))]`.\n */\n drawerBottomInsetClassName?: string\n /** Extra classes on the scrollable thread body. */\n drawerBodyClassName?: string\n /**\n * When `true` (default), the reply composer stays in a fixed footer below the scroll area.\n * When `false`, the composer scrolls with the thread.\n */\n drawerPinFooter?: boolean\n /** Merged onto the composer/footer wrapper when `drawerPinFooter` is true. */\n drawerFooterClassName?: string\n}\n", "type": "registry:block" } ], "type": "registry:block" }