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 || "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 (
+
+ );
+}
+export default CommentForm;
\ No newline at end of file
diff --git a/src/app/snippets/[id]/_components/Comments.tsx b/src/app/snippets/[id]/_components/Comments.tsx
new file mode 100644
index 0000000..862cc21
--- /dev/null
+++ b/src/app/snippets/[id]/_components/Comments.tsx
@@ -0,0 +1,85 @@
+import { SignInButton, useUser } from "@clerk/nextjs";
+import { Id } from "../../../../../convex/_generated/dataModel";
+import { useState } from "react";
+import { useMutation, useQuery } from "convex/react";
+import { api } from "../../../../../convex/_generated/api";
+import toast from "react-hot-toast";
+import { MessageSquare } from "lucide-react";
+import CommentForm from "./CommentForm";
+import Comment from "./Comment";
+
+
+function Comments({ snippetId }: { snippetId: Id<"snippets"> }) {
+ const { user } = useUser();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [deletinCommentId, setDeletingCommentId] = useState(null);
+
+ const comments = useQuery(api.snippets.getComments, { snippetId }) || [];
+ const addComment = useMutation(api.snippets.addComment);
+ const deleteComment = useMutation(api.snippets.deleteComment);
+
+ const handleSubmitComment = async (content: string) => {
+ setIsSubmitting(true);
+
+ try {
+ await addComment({ snippetId, content });
+ } catch (error) {
+ console.log("Error adding comment:", error);
+ toast.error("Something went wrong");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleDeleteComment = async (commentId: Id<"snippetComments">) => {
+ setDeletingCommentId(commentId);
+
+ try {
+ await deleteComment({ commentId });
+ } catch (error) {
+ console.log("Error deleting comment:", error);
+ toast.error("Something went wrong");
+ } finally {
+ setDeletingCommentId(null);
+ }
+ };
+
+ return (
+
+
+
+
+ Discussion ({comments.length})
+
+
+
+
+ {user ? (
+
+ ) : (
+
+
Sign in to join the discussion
+
+
+
+
+ )}
+
+
+ {comments.map((comment) => (
+
+ ))}
+
+
+
+ );
+}
+export default Comments;
\ No newline at end of file
diff --git a/src/app/snippets/[id]/_components/CopyButton.tsx b/src/app/snippets/[id]/_components/CopyButton.tsx
new file mode 100644
index 0000000..42342c8
--- /dev/null
+++ b/src/app/snippets/[id]/_components/CopyButton.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { Check, Copy } from "lucide-react";
+import { useState } from "react";
+
+function CopyButton({ code }: { code: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = async () => {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ );
+}
+
+export default CopyButton;
\ No newline at end of file
diff --git a/src/app/snippets/[id]/_components/SnippetDetailPageSkeleton.tsx b/src/app/snippets/[id]/_components/SnippetDetailPageSkeleton.tsx
index e223d64..b85b9dd 100644
--- a/src/app/snippets/[id]/_components/SnippetDetailPageSkeleton.tsx
+++ b/src/app/snippets/[id]/_components/SnippetDetailPageSkeleton.tsx
@@ -1,8 +1,47 @@
+import NavigationHeader from '@/components/ui/NavigationHeader'
import React from 'react'
function SnippetDetailPageSkeleton() {
return (
- SnippetDetailPageSkeleton
+
+
+
+
+ {/* Skeleton Header */}
+
+
+ {/* Skeleton Code Editor */}
+
+
+ {/* Skeleton Comments Section */}
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
)
}
diff --git a/src/app/snippets/[id]/page.tsx b/src/app/snippets/[id]/page.tsx
index 49a3f31..36eff06 100644
--- a/src/app/snippets/[id]/page.tsx
+++ b/src/app/snippets/[id]/page.tsx
@@ -6,11 +6,18 @@ import React from 'react'
import { api } from '../../../../convex/_generated/api';
import { Id } from '../../../../convex/_generated/dataModel';
import SnippetDetailPageSkeleton from './_components/SnippetDetailPageSkeleton';
+import NavigationHeader from '@/components/ui/NavigationHeader';
+import { Clock, Code, MessageSquare, User } from 'lucide-react';
+import { Editor } from '@monaco-editor/react';
+import { defineMonacoThemes, LANGUAGE_CONFIG } from '@/app/(home)/_constants';
+import CopyButton from './_components/CopyButton';
+import Comments from './_components/Comments';
function SnippetDetailPage() {
const snippetId = useParams().id;
const snippet = useQuery(api.snippets.getSnippetById, { snippetId : snippetId as Id<"snippets"> });
+ const comments = useQuery(api.snippets.getComments, { snippetId : snippetId as Id<"snippets"> });
if (snippet === undefined) {
return ;
@@ -18,7 +25,81 @@ function SnippetDetailPage() {
return (
- SnippetDetailPage
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+

+
+
+
+ {snippet.title}
+
+
+
+
+ {snippet.userName}
+
+
+
+ {new Date(snippet._creationTime).toLocaleDateString()}
+
+
+
+ {comments?.length} comments
+
+
+
+
+
+ {snippet.language}
+
+
+
+
+ {/* Code Editor */}
+
+
+
+
+
+
)
}
diff --git a/src/components/ui/NavigationHeader.tsx b/src/components/ui/NavigationHeader.tsx
index b970c50..98b0c1c 100644
--- a/src/components/ui/NavigationHeader.tsx
+++ b/src/components/ui/NavigationHeader.tsx
@@ -1,6 +1,7 @@
import HeaderProfileBtn from "@/app/(home)/_components/HeaderProfileBtn";
import { SignedOut } from "@clerk/nextjs";
import { Blocks, Code2, Sparkles } from "lucide-react";
+import Image from "next/image";
import Link from "next/link";
function NavigationHeader() {
@@ -19,19 +20,24 @@ function NavigationHeader() {
/>
{/* Logo */}
-
-
+
+
-
-
- CodeCraft
+
+
+ CodeX
- Interactive Code Editor
+ Open Source Code Editor | Releasing in 2025
@@ -79,4 +85,4 @@ function NavigationHeader() {
);
}
-export default NavigationHeader;
\ No newline at end of file
+export default NavigationHeader;