Skip to content

Commit

Permalink
feat: comments and snippet details page complete
Browse files Browse the repository at this point in the history
Signed-off-by: ARYPROGRAMMER <arya.2023ug1104@iiitranchi.ac.in>
  • Loading branch information
ARYPROGRAMMER committed Dec 21, 2024
1 parent 6d97594 commit 41adad6
Show file tree
Hide file tree
Showing 10 changed files with 530 additions and 12 deletions.
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" />

Check warning

Code scanning / CodeQL

DOM text reinterpreted as HTML Medium

DOM text
is reinterpreted as HTML without escaping meta-characters.
<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

0 comments on commit 41adad6

Please sign in to comment.