From 41adad69cc032e8d2fa14f30e865720aa36e954d Mon Sep 17 00:00:00 2001 From: ARYPROGRAMMER Date: Sun, 22 Dec 2024 04:28:35 +0530 Subject: [PATCH] feat: comments and snippet details page complete Signed-off-by: ARYPROGRAMMER --- convex/snippets.ts | 44 ++++++++ .../snippets/[id]/_components/CodeBlock.tsx | 44 ++++++++ src/app/snippets/[id]/_components/Comment.tsx | 53 +++++++++ .../[id]/_components/CommentContent.tsx | 31 ++++++ .../snippets/[id]/_components/CommentForm.tsx | 105 ++++++++++++++++++ .../snippets/[id]/_components/Comments.tsx | 85 ++++++++++++++ .../snippets/[id]/_components/CopyButton.tsx | 30 +++++ .../_components/SnippetDetailPageSkeleton.tsx | 41 ++++++- src/app/snippets/[id]/page.tsx | 83 +++++++++++++- src/components/ui/NavigationHeader.tsx | 26 +++-- 10 files changed, 530 insertions(+), 12 deletions(-) create mode 100644 src/app/snippets/[id]/_components/CodeBlock.tsx create mode 100644 src/app/snippets/[id]/_components/Comment.tsx create mode 100644 src/app/snippets/[id]/_components/CommentContent.tsx create mode 100644 src/app/snippets/[id]/_components/CommentForm.tsx create mode 100644 src/app/snippets/[id]/_components/Comments.tsx create mode 100644 src/app/snippets/[id]/_components/CopyButton.tsx diff --git a/convex/snippets.ts b/convex/snippets.ts index 2766272..df953bc 100644 --- a/convex/snippets.ts +++ b/convex/snippets.ts @@ -160,4 +160,48 @@ export const starSnippet = mutation({ }); } }, +}); + +export const addComment = mutation({ + args: { + snippetId: v.id("snippets"), + content: v.string(), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + const user = await ctx.db + .query("users") + .withIndex("by_user_id") + .filter((q) => q.eq(q.field("userId"), identity.subject)) + .first(); + + if (!user) throw new Error("User not found"); + + return await ctx.db.insert("snippetComments", { + snippetId: args.snippetId, + userId: identity.subject, + userName: user.name, + content: args.content, + }); + }, +}); + +export const deleteComment = mutation({ + args: { commentId: v.id("snippetComments") }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Not authenticated"); + + const comment = await ctx.db.get(args.commentId); + if (!comment) throw new Error("Comment not found"); + + // Check if the user is the comment author + if (comment.userId !== identity.subject) { + throw new Error("Not authorized to delete this comment"); + } + + await ctx.db.delete(args.commentId); + }, }); \ No newline at end of file diff --git a/src/app/snippets/[id]/_components/CodeBlock.tsx b/src/app/snippets/[id]/_components/CodeBlock.tsx new file mode 100644 index 0000000..c6f8bed --- /dev/null +++ b/src/app/snippets/[id]/_components/CodeBlock.tsx @@ -0,0 +1,44 @@ +import SyntaxHighlighter from "react-syntax-highlighter"; +import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import CopyButton from "./CopyButton"; + +const CodeBlock = ({ language, code }: { language: string; code: string }) => { + const trimmedCode = code + .split("\n") // split into lines + .map((line) => line.trimEnd()) // remove trailing spaces from each line + .join("\n"); // join back into a single string + + return ( +
+ {/* header bar showing language and copy button */} +
+ {/* language indicator with icon */} +
+ {language} + {language || "plaintext"} +
+ {/* button to copy code to clipboard */} + +
+ + {/* code block with syntax highlighting */} +
+ + {trimmedCode} + +
+
+ ); +}; + +export default CodeBlock; \ No newline at end of file diff --git a/src/app/snippets/[id]/_components/Comment.tsx b/src/app/snippets/[id]/_components/Comment.tsx new file mode 100644 index 0000000..db2a981 --- /dev/null +++ b/src/app/snippets/[id]/_components/Comment.tsx @@ -0,0 +1,53 @@ +import { Trash2Icon, UserIcon } from "lucide-react"; +import { Id } from "../../../../../convex/_generated/dataModel"; +import CommentContent from "./CommentContent"; + + +interface CommentProps { + comment: { + _id: Id<"snippetComments">; + _creationTime: number; + userId: string; + userName: string; + snippetId: Id<"snippets">; + content: string; + }; + onDelete: (commentId: Id<"snippetComments">) => void; + isDeleting: boolean; + currentUserId?: string; +} +function Comment({ comment, currentUserId, isDeleting, onDelete }: CommentProps) { + return ( +
+
+
+
+
+ +
+
+ {comment.userName} + + {new Date(comment._creationTime).toLocaleDateString()} + +
+
+ + {comment.userId === currentUserId && ( + + )} +
+ + +
+
+ ); +} +export default Comment; \ No newline at end of file diff --git a/src/app/snippets/[id]/_components/CommentContent.tsx b/src/app/snippets/[id]/_components/CommentContent.tsx new file mode 100644 index 0000000..e565292 --- /dev/null +++ b/src/app/snippets/[id]/_components/CommentContent.tsx @@ -0,0 +1,31 @@ +import CodeBlock from "./CodeBlock"; + +function CommentContent({ content }: { content: string }) { + // regex + const parts = content.split(/(```[\w-]*\n[\s\S]*?\n```)/g); + + return ( +
+ {parts.map((part, index) => { + if (part.startsWith("```")) { + // ```javascript + // const name = "John"; + // ``` + const match = part.match(/```([\w-]*)\n([\s\S]*?)\n```/); + + if (match) { + const [, language, code] = match; + return ; + } + } + + return part.split("\n").map((line, lineIdx) => ( +

+ {line} +

+ )); + })} +
+ ); +} +export default CommentContent; \ No newline at end of file diff --git a/src/app/snippets/[id]/_components/CommentForm.tsx b/src/app/snippets/[id]/_components/CommentForm.tsx new file mode 100644 index 0000000..211d62c --- /dev/null +++ b/src/app/snippets/[id]/_components/CommentForm.tsx @@ -0,0 +1,105 @@ +import { CodeIcon, SendIcon } from "lucide-react"; +import { useState } from "react"; +import CommentContent from "./CommentContent"; + + +interface CommentFormProps { + onSubmit: (comment: string) => Promise; + isSubmitting: boolean; +} + +function CommentForm({ isSubmitting, onSubmit }: CommentFormProps) { + const [comment, setComment] = useState(""); + const [isPreview, setIsPreview] = useState(false); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + const start = e.currentTarget.selectionStart; + const end = e.currentTarget.selectionEnd; + const newComment = comment.substring(0, start) + " " + comment.substring(end); + setComment(newComment); + e.currentTarget.selectionStart = e.currentTarget.selectionEnd = start + 2; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!comment.trim()) return; + + await onSubmit(comment); + + setComment(""); + setIsPreview(false); + }; + + return ( +
+
+ {/* Comment form header */} +
+ +
+ + {/* Comment form body */} + {isPreview ? ( +
+ +
+ ) : ( +