Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions convex/snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
44 changes: 44 additions & 0 deletions src/app/snippets/[id]/_components/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="my-4 bg-[#0a0a0f] rounded-lg overflow-hidden border border-[#ffffff0a]">
{/* header bar showing language and copy button */}
<div className="flex items-center justify-between px-4 py-2 bg-[#ffffff08]">
{/* language indicator with icon */}
<div className="flex items-center gap-2">
<img src={`/${language}.png`} alt={language} className="size-4 object-contain" />
<span className="text-sm text-gray-400">{language || "plaintext"}</span>
</div>
{/* button to copy code to clipboard */}
<CopyButton code={trimmedCode} />
</div>

{/* code block with syntax highlighting */}
<div className="relative">
<SyntaxHighlighter
language={language || "plaintext"}
style={atomOneDark} // dark theme for the code
customStyle={{
padding: "1rem",
background: "transparent",
margin: 0,
}}
showLineNumbers={true}
wrapLines={true} // wrap long lines
>
{trimmedCode}
</SyntaxHighlighter>
</div>
</div>
);
};

export default CodeBlock;
53 changes: 53 additions & 0 deletions src/app/snippets/[id]/_components/Comment.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="group">
<div className="bg-[#0a0a0f] rounded-xl p-6 border border-[#ffffff0a] hover:border-[#ffffff14] transition-all">
<div className="flex items-start sm:items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#ffffff08] flex items-center justify-center flex-shrink-0">
<UserIcon className="w-4 h-4 text-[#808086]" />
</div>
<div className="min-w-0">
<span className="block text-[#e1e1e3] font-medium truncate">{comment.userName}</span>
<span className="block text-sm text-[#808086]">
{new Date(comment._creationTime).toLocaleDateString()}
</span>
</div>
</div>

{comment.userId === currentUserId && (
<button
onClick={() => onDelete(comment._id)}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 p-2 hover:bg-red-500/10 rounded-lg transition-all"
title="Delete comment"
>
<Trash2Icon className="w-4 h-4 text-red-400" />
</button>
)}
</div>

<CommentContent content={comment.content} />
</div>
</div>
);
}
export default Comment;
31 changes: 31 additions & 0 deletions src/app/snippets/[id]/_components/CommentContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-none text-white">
{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 <CodeBlock language={language} code={code} key={index} />;
}
}

return part.split("\n").map((line, lineIdx) => (
<p key={lineIdx} className="mb-4 text-gray-300 last:mb-0">
{line}
</p>
));
})}
</div>
);
}
export default CommentContent;
105 changes: 105 additions & 0 deletions src/app/snippets/[id]/_components/CommentForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { CodeIcon, SendIcon } from "lucide-react";
import { useState } from "react";
import CommentContent from "./CommentContent";


interface CommentFormProps {
onSubmit: (comment: string) => Promise<void>;
isSubmitting: boolean;
}

function CommentForm({ isSubmitting, onSubmit }: CommentFormProps) {
const [comment, setComment] = useState("");
const [isPreview, setIsPreview] = useState(false);

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<form onSubmit={handleSubmit} className="mb-8">
<div className="bg-[#0a0a0f] rounded-xl border border-[#ffffff0a] overflow-hidden">
{/* Comment form header */}
<div className="flex justify-end gap-2 px-4 pt-2">
<button
type="button"
onClick={() => setIsPreview(!isPreview)}
className={`text-sm px-3 py-1 rounded-md transition-colors ${
isPreview ? "bg-blue-500/10 text-blue-400" : "hover:bg-[#ffffff08] text-gray-400"
}`}
>
{isPreview ? "Edit" : "Preview"}
</button>
</div>

{/* Comment form body */}
{isPreview ? (
<div className="min-h-[120px] p-4 text-[#e1e1e3">
<CommentContent content={comment} />
</div>
) : (
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add to the discussion..."
className="w-full bg-transparent border-0 text-[#e1e1e3] placeholder:text-[#808086] outline-none
resize-none min-h-[120px] p-4 font-mono text-sm"
/>
)}

{/* Comment Form Footer */}
<div className="flex items-center justify-between gap-4 px-4 py-3 bg-[#080809] border-t border-[#ffffff0a]">
<div className="hidden sm:block text-xs text-[#808086] space-y-1">
<div className="flex items-center gap-2">
<CodeIcon className="w-3.5 h-3.5" />
<span>Format code with ```language</span>
</div>
<div className="text-[#808086]/60 pl-5">
Tab key inserts spaces • Preview your comment before posting
</div>
</div>
<button
type="submit"
disabled={isSubmitting || !comment.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[#3b82f6] text-white rounded-lg hover:bg-[#2563eb] disabled:opacity-50 disabled:cursor-not-allowed transition-all ml-auto"
>
{isSubmitting ? (
<>
<div
className="w-4 h-4 border-2 border-white/30
border-t-white rounded-full animate-spin"
/>
<span>Posting...</span>
</>
) : (
<>
<SendIcon className="w-4 h-4" />
<span>Comment</span>
</>
)}
</button>
</div>
</div>
</form>
);
}
export default CommentForm;
85 changes: 85 additions & 0 deletions src/app/snippets/[id]/_components/Comments.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="bg-[#121218] border border-[#ffffff0a] rounded-2xl overflow-hidden">
<div className="px-6 sm:px-8 py-6 border-b border-[#ffffff0a]">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
Discussion ({comments.length})
</h2>
</div>

<div className="p-6 sm:p-8">
{user ? (
<CommentForm onSubmit={handleSubmitComment} isSubmitting={isSubmitting} />
) : (
<div className="bg-[#0a0a0f] rounded-xl p-6 text-center mb-8 border border-[#ffffff0a]">
<p className="text-[#808086] mb-4">Sign in to join the discussion</p>
<SignInButton mode="modal">
<button className="px-6 py-2 bg-[#3b82f6] text-white rounded-lg hover:bg-[#2563eb] transition-colors">
Sign In
</button>
</SignInButton>
</div>
)}

<div className="space-y-6">
{comments.map((comment) => (
<Comment
key={comment._id}
comment={comment}
onDelete={handleDeleteComment}
isDeleting={deletinCommentId === comment._id}
currentUserId={user?.id}
/>
))}
</div>
</div>
</div>
);
}
export default Comments;
Loading
Loading