Skip to content

Commit 6a4fdad

Browse files
committed
Add post comments list, add comment with revalidation, and delete with priviledge checking
1 parent 56e23c0 commit 6a4fdad

5 files changed

Lines changed: 309 additions & 9 deletions

File tree

‎apps/codebility/app/home/feeds/_components/PostView.tsx‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import remarkGfm from "remark-gfm";
1717
import type { PostType } from "../_services/query";
1818
import PostUpvote from "../_components/PostUpvote";
1919
import { getUserRole } from "../_services/action";
20+
import PostViewCommentList from "./PostViewCommentList";
21+
import PostViewCreateComment from "./PostViewCreateComment";
2022

2123
interface PostViewProps {
2224
postId: string;
@@ -36,6 +38,11 @@ export default function PostView({ postId }: PostViewProps) {
3638
const [isAdmin, setIsAdmin] = useState(true);
3739
const [isAuthor, setIsAuthor] = useState(false);
3840
const { user } = useUserStore();
41+
const [refreshComments, setRefreshComments] = useState(0);
42+
43+
const triggerRefreshComments = () => {
44+
setRefreshComments((prev) => prev + 1);
45+
};
3946

4047
useEffect(() => {
4148
const fetchRole = async () => {
@@ -136,17 +143,31 @@ export default function PostView({ postId }: PostViewProps) {
136143

137144
<PostUpvote post={post} />
138145

139-
<div className="prose prose-sm dark:prose-invert max-h-[800px] min-h-[160px] max-w-none overflow-y-auto p-4 text-sm">
140-
<ReactMarkdown remarkPlugins={[remarkGfm]}>
141-
{`![Post image](${post.image_url || "/assets/images/bg-certificate.png"})\n\n${post.content}`}
142-
</ReactMarkdown>
146+
{/* Scrollable group */}
147+
<div className="max-h-[90vh] overflow-y-auto">
148+
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
149+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
150+
{`![Post image](${post.image_url || "/assets/images/bg-certificate.png"})\n\n${post.content}`}
151+
</ReactMarkdown>
152+
</div>
153+
154+
{(isAdmin || isAuthor) && (
155+
<Button className="mb-4 mt-4 w-auto" onClick={openModal}>
156+
Edit Post
157+
</Button>
158+
)}
159+
<div className="mb-1 mt-1 border-t border-gray-200 dark:border-gray-700" />
160+
<PostViewCommentList
161+
postId={postId}
162+
refresh={refreshComments}
163+
hasDeleteCommentPrivilege={isAdmin || isAuthor}
164+
/>
143165
</div>
166+
<PostViewCreateComment
167+
postId={postId}
168+
onCommentCreated={triggerRefreshComments}
169+
/>
144170

145-
{(isAdmin || isAuthor) && (
146-
<Button className="mb-4 mt-4 w-auto" onClick={openModal}>
147-
Edit Post
148-
</Button>
149-
)}
150171
<EditPostModal
151172
post={post}
152173
isOpen={isModalOpen}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import Image from "next/image";
5+
import { X } from "lucide-react";
6+
7+
import { deletePostComment } from "../_services/action";
8+
9+
interface PostViewCommentItemProps {
10+
commenntId: string;
11+
userImage: string;
12+
userName: string;
13+
content: string;
14+
userCanDelete: boolean;
15+
}
16+
17+
export default function PostViewCommentItem({
18+
commenntId,
19+
userImage,
20+
userName,
21+
content,
22+
userCanDelete,
23+
}: PostViewCommentItemProps) {
24+
const [isVisible, setIsVisible] = useState(true);
25+
26+
const handleDelete = async () => {
27+
setIsVisible(false);
28+
await deletePostComment(commenntId);
29+
};
30+
31+
if (!isVisible) return null;
32+
33+
return (
34+
<div className="group relative flex items-start gap-3 rounded-md p-2 transition hover:bg-gray-50 dark:hover:bg-gray-800/40">
35+
{/* Avatar */}
36+
<Image
37+
src={
38+
userImage ||
39+
"https://codebility-cdn.pages.dev/assets/images/default-avatar-200x200.jpg"
40+
}
41+
alt={`${userName}'s avatar`}
42+
width={36}
43+
height={36}
44+
className="h-9 w-9 rounded-full object-cover"
45+
/>
46+
47+
{/* User content */}
48+
<div className="flex flex-col">
49+
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100">
50+
{userName}
51+
</span>
52+
<p className="break-words text-sm text-gray-700 dark:text-gray-300">
53+
{content}
54+
</p>
55+
</div>
56+
57+
{/* X Icon (only visible on hover) */}
58+
{userCanDelete && (
59+
<button
60+
onClick={handleDelete}
61+
className="absolute right-2 top-2 rounded-full p-1 opacity-0 transition-opacity hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-700"
62+
aria-label="Delete comment"
63+
>
64+
<X size={16} className="text-gray-600 dark:text-gray-300" />
65+
</button>
66+
)}
67+
</div>
68+
);
69+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { defaultAvatar } from "@/public/assets/images";
5+
import { useUserStore } from "@/store/codev-store";
6+
7+
import { getPostComments } from "../_services/action";
8+
import PostViewCommentItem from "./PostViewCommentItem";
9+
10+
interface PostViewCommentListProps {
11+
postId: string;
12+
refresh: number;
13+
hasDeleteCommentPrivilege: boolean;
14+
}
15+
16+
interface Comment {
17+
id: string;
18+
content: string;
19+
created_at: string;
20+
commenter: any;
21+
}
22+
23+
export default function PostViewCommentList({
24+
postId,
25+
refresh,
26+
hasDeleteCommentPrivilege,
27+
}: PostViewCommentListProps) {
28+
const [comments, setComments] = useState<Comment[]>([]);
29+
const [loading, setLoading] = useState(true);
30+
const { user } = useUserStore();
31+
32+
useEffect(() => {
33+
const fetchComments = async () => {
34+
try {
35+
const data = await getPostComments(postId);
36+
setComments(data);
37+
} catch (error) {
38+
console.error("Error fetching comments:", error);
39+
} finally {
40+
setLoading(false);
41+
}
42+
};
43+
44+
fetchComments();
45+
}, [postId, refresh]);
46+
47+
if (loading) {
48+
return (
49+
<p className="text-sm text-gray-500 dark:text-gray-400">
50+
Loading comments…
51+
</p>
52+
);
53+
}
54+
55+
if (comments.length === 0) {
56+
return (
57+
<p className="text-sm text-gray-500 dark:text-gray-400">
58+
No comments yet.
59+
</p>
60+
);
61+
}
62+
63+
return (
64+
<div className="flex flex-col gap-2">
65+
{comments.map((c) => (
66+
<PostViewCommentItem
67+
key={c.id}
68+
commenntId={c.id}
69+
userImage={c.commenter?.image_url || defaultAvatar}
70+
userName={`${c.commenter?.first_name} ${c.commenter?.last_name}`}
71+
content={c.content}
72+
userCanDelete={
73+
user?.id == c.commenter?.id || hasDeleteCommentPrivilege
74+
}
75+
/>
76+
))}
77+
</div>
78+
);
79+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import Image from "next/image";
5+
import { defaultAvatar } from "@/public/assets/images";
6+
import { useUserStore } from "@/store/codev-store";
7+
8+
import { createComment } from "../_services/action";
9+
10+
interface CreateCommentProps {
11+
postId: string;
12+
onCommentCreated?: () => void;
13+
}
14+
15+
export default function PostViewCreateComment({
16+
postId,
17+
onCommentCreated,
18+
}: CreateCommentProps) {
19+
const [comment, setComment] = useState("");
20+
const [isSubmitting, setIsSubmitting] = useState(false);
21+
const { user } = useUserStore();
22+
23+
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
24+
if (e.key === "Enter" && comment.trim() && !isSubmitting) {
25+
try {
26+
setIsSubmitting(true);
27+
await createComment(postId, user?.id!, comment);
28+
setComment("");
29+
onCommentCreated?.();
30+
} finally {
31+
setIsSubmitting(false);
32+
}
33+
}
34+
};
35+
36+
return (
37+
<div className="flex items-center gap-3 p-2">
38+
<Image
39+
src={user?.image_url || defaultAvatar}
40+
alt="User avatar"
41+
width={36}
42+
height={36}
43+
className="h-9 w-9 rounded-full object-cover"
44+
/>
45+
<input
46+
type="text"
47+
placeholder="Write a comment..."
48+
value={comment}
49+
onChange={(e) => setComment(e.target.value)}
50+
onKeyDown={handleKeyDown}
51+
disabled={isSubmitting}
52+
className={`flex-1 rounded-full border border-gray-300 bg-white px-4 py-2 text-sm outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white ${
53+
isSubmitting
54+
? "cursor-not-allowed opacity-50"
55+
: "focus:border-gray-500"
56+
}`}
57+
/>
58+
</div>
59+
);
60+
}

‎apps/codebility/app/home/feeds/_services/action.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,74 @@ export async function deletePostContentImages(
409409
throw error;
410410
}
411411
}
412+
413+
414+
export async function createComment(
415+
post_id: string,
416+
commenter_id: string,
417+
content: string,
418+
) {
419+
const supabase = await createClientServerComponent();
420+
421+
try {
422+
const { data, error } = await supabase
423+
.from("post_comments")
424+
.insert([{ post_id, commenter_id, content }])
425+
.select()
426+
.single();
427+
428+
if (error) throw error;
429+
430+
return data;
431+
} catch (error) {
432+
console.error("Comment creation failed:", error);
433+
throw error;
434+
}
435+
}
436+
437+
export async function getPostComments(post_id: string) {
438+
const supabase = await createClientServerComponent();
439+
440+
try {
441+
const { data, error } = await supabase
442+
.from("post_comments")
443+
.select(`
444+
id,
445+
content,
446+
created_at,
447+
commenter: commenter_id!inner (
448+
id,
449+
first_name,
450+
last_name,
451+
image_url
452+
)
453+
`)
454+
.eq("post_id", post_id)
455+
.order("created_at", { ascending: true });
456+
457+
if (error) throw error;
458+
459+
return data;
460+
} catch (error) {
461+
console.error("Failed to fetch post comments:", error);
462+
throw error;
463+
}
464+
}
465+
466+
export async function deletePostComment(comment_id: string) {
467+
const supabase = await createClientServerComponent();
468+
469+
try {
470+
const { error } = await supabase
471+
.from("post_comments")
472+
.delete()
473+
.eq("id", comment_id);
474+
475+
if (error) throw error;
476+
477+
return { success: true };
478+
} catch (error) {
479+
console.error("Failed to delete post comment:", error);
480+
throw error;
481+
}
482+
}

0 commit comments

Comments
 (0)