Skip to content

Commit aefa7f4

Browse files
Merge pull request #18 from ARYPROGRAMMER/develop/home
feat: comments and snippet details page complete
2 parents 4877877 + 41adad6 commit aefa7f4

File tree

10 files changed

+530
-12
lines changed

10 files changed

+530
-12
lines changed

convex/snippets.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,48 @@ export const starSnippet = mutation({
160160
});
161161
}
162162
},
163+
});
164+
165+
export const addComment = mutation({
166+
args: {
167+
snippetId: v.id("snippets"),
168+
content: v.string(),
169+
},
170+
handler: async (ctx, args) => {
171+
const identity = await ctx.auth.getUserIdentity();
172+
if (!identity) throw new Error("Not authenticated");
173+
174+
const user = await ctx.db
175+
.query("users")
176+
.withIndex("by_user_id")
177+
.filter((q) => q.eq(q.field("userId"), identity.subject))
178+
.first();
179+
180+
if (!user) throw new Error("User not found");
181+
182+
return await ctx.db.insert("snippetComments", {
183+
snippetId: args.snippetId,
184+
userId: identity.subject,
185+
userName: user.name,
186+
content: args.content,
187+
});
188+
},
189+
});
190+
191+
export const deleteComment = mutation({
192+
args: { commentId: v.id("snippetComments") },
193+
handler: async (ctx, args) => {
194+
const identity = await ctx.auth.getUserIdentity();
195+
if (!identity) throw new Error("Not authenticated");
196+
197+
const comment = await ctx.db.get(args.commentId);
198+
if (!comment) throw new Error("Comment not found");
199+
200+
// Check if the user is the comment author
201+
if (comment.userId !== identity.subject) {
202+
throw new Error("Not authorized to delete this comment");
203+
}
204+
205+
await ctx.db.delete(args.commentId);
206+
},
163207
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import SyntaxHighlighter from "react-syntax-highlighter";
2+
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
3+
import CopyButton from "./CopyButton";
4+
5+
const CodeBlock = ({ language, code }: { language: string; code: string }) => {
6+
const trimmedCode = code
7+
.split("\n") // split into lines
8+
.map((line) => line.trimEnd()) // remove trailing spaces from each line
9+
.join("\n"); // join back into a single string
10+
11+
return (
12+
<div className="my-4 bg-[#0a0a0f] rounded-lg overflow-hidden border border-[#ffffff0a]">
13+
{/* header bar showing language and copy button */}
14+
<div className="flex items-center justify-between px-4 py-2 bg-[#ffffff08]">
15+
{/* language indicator with icon */}
16+
<div className="flex items-center gap-2">
17+
<img src={`/${language}.png`} alt={language} className="size-4 object-contain" />
18+
<span className="text-sm text-gray-400">{language || "plaintext"}</span>
19+
</div>
20+
{/* button to copy code to clipboard */}
21+
<CopyButton code={trimmedCode} />
22+
</div>
23+
24+
{/* code block with syntax highlighting */}
25+
<div className="relative">
26+
<SyntaxHighlighter
27+
language={language || "plaintext"}
28+
style={atomOneDark} // dark theme for the code
29+
customStyle={{
30+
padding: "1rem",
31+
background: "transparent",
32+
margin: 0,
33+
}}
34+
showLineNumbers={true}
35+
wrapLines={true} // wrap long lines
36+
>
37+
{trimmedCode}
38+
</SyntaxHighlighter>
39+
</div>
40+
</div>
41+
);
42+
};
43+
44+
export default CodeBlock;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Trash2Icon, UserIcon } from "lucide-react";
2+
import { Id } from "../../../../../convex/_generated/dataModel";
3+
import CommentContent from "./CommentContent";
4+
5+
6+
interface CommentProps {
7+
comment: {
8+
_id: Id<"snippetComments">;
9+
_creationTime: number;
10+
userId: string;
11+
userName: string;
12+
snippetId: Id<"snippets">;
13+
content: string;
14+
};
15+
onDelete: (commentId: Id<"snippetComments">) => void;
16+
isDeleting: boolean;
17+
currentUserId?: string;
18+
}
19+
function Comment({ comment, currentUserId, isDeleting, onDelete }: CommentProps) {
20+
return (
21+
<div className="group">
22+
<div className="bg-[#0a0a0f] rounded-xl p-6 border border-[#ffffff0a] hover:border-[#ffffff14] transition-all">
23+
<div className="flex items-start sm:items-center justify-between gap-4 mb-4">
24+
<div className="flex items-center gap-3">
25+
<div className="w-9 h-9 rounded-full bg-[#ffffff08] flex items-center justify-center flex-shrink-0">
26+
<UserIcon className="w-4 h-4 text-[#808086]" />
27+
</div>
28+
<div className="min-w-0">
29+
<span className="block text-[#e1e1e3] font-medium truncate">{comment.userName}</span>
30+
<span className="block text-sm text-[#808086]">
31+
{new Date(comment._creationTime).toLocaleDateString()}
32+
</span>
33+
</div>
34+
</div>
35+
36+
{comment.userId === currentUserId && (
37+
<button
38+
onClick={() => onDelete(comment._id)}
39+
disabled={isDeleting}
40+
className="opacity-0 group-hover:opacity-100 p-2 hover:bg-red-500/10 rounded-lg transition-all"
41+
title="Delete comment"
42+
>
43+
<Trash2Icon className="w-4 h-4 text-red-400" />
44+
</button>
45+
)}
46+
</div>
47+
48+
<CommentContent content={comment.content} />
49+
</div>
50+
</div>
51+
);
52+
}
53+
export default Comment;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import CodeBlock from "./CodeBlock";
2+
3+
function CommentContent({ content }: { content: string }) {
4+
// regex
5+
const parts = content.split(/(```[\w-]*\n[\s\S]*?\n```)/g);
6+
7+
return (
8+
<div className="max-w-none text-white">
9+
{parts.map((part, index) => {
10+
if (part.startsWith("```")) {
11+
// ```javascript
12+
// const name = "John";
13+
// ```
14+
const match = part.match(/```([\w-]*)\n([\s\S]*?)\n```/);
15+
16+
if (match) {
17+
const [, language, code] = match;
18+
return <CodeBlock language={language} code={code} key={index} />;
19+
}
20+
}
21+
22+
return part.split("\n").map((line, lineIdx) => (
23+
<p key={lineIdx} className="mb-4 text-gray-300 last:mb-0">
24+
{line}
25+
</p>
26+
));
27+
})}
28+
</div>
29+
);
30+
}
31+
export default CommentContent;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { CodeIcon, SendIcon } from "lucide-react";
2+
import { useState } from "react";
3+
import CommentContent from "./CommentContent";
4+
5+
6+
interface CommentFormProps {
7+
onSubmit: (comment: string) => Promise<void>;
8+
isSubmitting: boolean;
9+
}
10+
11+
function CommentForm({ isSubmitting, onSubmit }: CommentFormProps) {
12+
const [comment, setComment] = useState("");
13+
const [isPreview, setIsPreview] = useState(false);
14+
15+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
16+
if (e.key === "Tab") {
17+
e.preventDefault();
18+
const start = e.currentTarget.selectionStart;
19+
const end = e.currentTarget.selectionEnd;
20+
const newComment = comment.substring(0, start) + " " + comment.substring(end);
21+
setComment(newComment);
22+
e.currentTarget.selectionStart = e.currentTarget.selectionEnd = start + 2;
23+
}
24+
};
25+
26+
const handleSubmit = async (e: React.FormEvent) => {
27+
e.preventDefault();
28+
29+
if (!comment.trim()) return;
30+
31+
await onSubmit(comment);
32+
33+
setComment("");
34+
setIsPreview(false);
35+
};
36+
37+
return (
38+
<form onSubmit={handleSubmit} className="mb-8">
39+
<div className="bg-[#0a0a0f] rounded-xl border border-[#ffffff0a] overflow-hidden">
40+
{/* Comment form header */}
41+
<div className="flex justify-end gap-2 px-4 pt-2">
42+
<button
43+
type="button"
44+
onClick={() => setIsPreview(!isPreview)}
45+
className={`text-sm px-3 py-1 rounded-md transition-colors ${
46+
isPreview ? "bg-blue-500/10 text-blue-400" : "hover:bg-[#ffffff08] text-gray-400"
47+
}`}
48+
>
49+
{isPreview ? "Edit" : "Preview"}
50+
</button>
51+
</div>
52+
53+
{/* Comment form body */}
54+
{isPreview ? (
55+
<div className="min-h-[120px] p-4 text-[#e1e1e3">
56+
<CommentContent content={comment} />
57+
</div>
58+
) : (
59+
<textarea
60+
value={comment}
61+
onChange={(e) => setComment(e.target.value)}
62+
onKeyDown={handleKeyDown}
63+
placeholder="Add to the discussion..."
64+
className="w-full bg-transparent border-0 text-[#e1e1e3] placeholder:text-[#808086] outline-none
65+
resize-none min-h-[120px] p-4 font-mono text-sm"
66+
/>
67+
)}
68+
69+
{/* Comment Form Footer */}
70+
<div className="flex items-center justify-between gap-4 px-4 py-3 bg-[#080809] border-t border-[#ffffff0a]">
71+
<div className="hidden sm:block text-xs text-[#808086] space-y-1">
72+
<div className="flex items-center gap-2">
73+
<CodeIcon className="w-3.5 h-3.5" />
74+
<span>Format code with ```language</span>
75+
</div>
76+
<div className="text-[#808086]/60 pl-5">
77+
Tab key inserts spaces • Preview your comment before posting
78+
</div>
79+
</div>
80+
<button
81+
type="submit"
82+
disabled={isSubmitting || !comment.trim()}
83+
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"
84+
>
85+
{isSubmitting ? (
86+
<>
87+
<div
88+
className="w-4 h-4 border-2 border-white/30
89+
border-t-white rounded-full animate-spin"
90+
/>
91+
<span>Posting...</span>
92+
</>
93+
) : (
94+
<>
95+
<SendIcon className="w-4 h-4" />
96+
<span>Comment</span>
97+
</>
98+
)}
99+
</button>
100+
</div>
101+
</div>
102+
</form>
103+
);
104+
}
105+
export default CommentForm;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { SignInButton, useUser } from "@clerk/nextjs";
2+
import { Id } from "../../../../../convex/_generated/dataModel";
3+
import { useState } from "react";
4+
import { useMutation, useQuery } from "convex/react";
5+
import { api } from "../../../../../convex/_generated/api";
6+
import toast from "react-hot-toast";
7+
import { MessageSquare } from "lucide-react";
8+
import CommentForm from "./CommentForm";
9+
import Comment from "./Comment";
10+
11+
12+
function Comments({ snippetId }: { snippetId: Id<"snippets"> }) {
13+
const { user } = useUser();
14+
const [isSubmitting, setIsSubmitting] = useState(false);
15+
const [deletinCommentId, setDeletingCommentId] = useState<string | null>(null);
16+
17+
const comments = useQuery(api.snippets.getComments, { snippetId }) || [];
18+
const addComment = useMutation(api.snippets.addComment);
19+
const deleteComment = useMutation(api.snippets.deleteComment);
20+
21+
const handleSubmitComment = async (content: string) => {
22+
setIsSubmitting(true);
23+
24+
try {
25+
await addComment({ snippetId, content });
26+
} catch (error) {
27+
console.log("Error adding comment:", error);
28+
toast.error("Something went wrong");
29+
} finally {
30+
setIsSubmitting(false);
31+
}
32+
};
33+
34+
const handleDeleteComment = async (commentId: Id<"snippetComments">) => {
35+
setDeletingCommentId(commentId);
36+
37+
try {
38+
await deleteComment({ commentId });
39+
} catch (error) {
40+
console.log("Error deleting comment:", error);
41+
toast.error("Something went wrong");
42+
} finally {
43+
setDeletingCommentId(null);
44+
}
45+
};
46+
47+
return (
48+
<div className="bg-[#121218] border border-[#ffffff0a] rounded-2xl overflow-hidden">
49+
<div className="px-6 sm:px-8 py-6 border-b border-[#ffffff0a]">
50+
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
51+
<MessageSquare className="w-5 h-5" />
52+
Discussion ({comments.length})
53+
</h2>
54+
</div>
55+
56+
<div className="p-6 sm:p-8">
57+
{user ? (
58+
<CommentForm onSubmit={handleSubmitComment} isSubmitting={isSubmitting} />
59+
) : (
60+
<div className="bg-[#0a0a0f] rounded-xl p-6 text-center mb-8 border border-[#ffffff0a]">
61+
<p className="text-[#808086] mb-4">Sign in to join the discussion</p>
62+
<SignInButton mode="modal">
63+
<button className="px-6 py-2 bg-[#3b82f6] text-white rounded-lg hover:bg-[#2563eb] transition-colors">
64+
Sign In
65+
</button>
66+
</SignInButton>
67+
</div>
68+
)}
69+
70+
<div className="space-y-6">
71+
{comments.map((comment) => (
72+
<Comment
73+
key={comment._id}
74+
comment={comment}
75+
onDelete={handleDeleteComment}
76+
isDeleting={deletinCommentId === comment._id}
77+
currentUserId={user?.id}
78+
/>
79+
))}
80+
</div>
81+
</div>
82+
</div>
83+
);
84+
}
85+
export default Comments;

0 commit comments

Comments
 (0)