From 6452ffca6c6fa26411fbc9c569ada4401b4e695d Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sat, 2 May 2026 00:55:14 +0300 Subject: [PATCH 01/10] feat #52: add comment server actions (create, edit, delete, vote, save, report) --- src/actions/comments.ts | 273 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 src/actions/comments.ts diff --git a/src/actions/comments.ts b/src/actions/comments.ts new file mode 100644 index 0000000..8eec61d --- /dev/null +++ b/src/actions/comments.ts @@ -0,0 +1,273 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { PostStatus } from "@/generated/prisma/client"; + +const CommentBodySchema = z.object({ + body: z + .string() + .trim() + .min(1, "Comment cannot be empty.") + .max(10000, "Comment is too long."), +}); + +export type CommentActionResult = { + error?: string; + success?: string; +}; + +export async function createCommentAction( + postId: string, + body: string, + parentCommentId?: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in to comment." }; + + const validated = CommentBodySchema.safeParse({ body }); + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Invalid comment." }; + } + + const post = await prisma.post.findUnique({ + where: { id: postId, isDeleted: false, status: PostStatus.PUBLISHED }, + select: { id: true }, + }); + if (!post) return { error: "Post not found." }; + + if (parentCommentId) { + const parent = await prisma.comment.findUnique({ + where: { id: parentCommentId, postId }, + select: { id: true }, + }); + if (!parent) return { error: "Parent comment not found." }; + } + + try { + await prisma.comment.create({ + data: { + postId, + userId: session.user.id, + body: validated.data.body, + parentCommentId: parentCommentId ?? null, + }, + }); + + revalidatePath(`/posts/${postId}`); + return { success: "Comment posted." }; + } catch { + return { error: "Something went wrong." }; + } +} + +export async function editCommentAction( + commentId: string, + body: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in." }; + + const validated = CommentBodySchema.safeParse({ body }); + if (!validated.success) { + return { error: validated.error.issues[0]?.message ?? "Invalid comment." }; + } + + const comment = await prisma.comment.findUnique({ + where: { id: commentId, isDeleted: false }, + select: { userId: true, body: true, postId: true }, + }); + + if (!comment) return { error: "Comment not found." }; + if (comment.userId !== session.user.id) + return { error: "You cannot edit this comment." }; + + try { + await prisma.$transaction([ + prisma.commentEditHistory.create({ + data: { commentId, previousBody: comment.body }, + }), + prisma.comment.update({ + where: { id: commentId }, + data: { body: validated.data.body }, + }), + ]); + + revalidatePath(`/posts/${comment.postId}`); + return { success: "Comment updated." }; + } catch { + return { error: "Something went wrong." }; + } +} + +export async function deleteCommentAction( + commentId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in." }; + + const comment = await prisma.comment.findUnique({ + where: { id: commentId, isDeleted: false }, + select: { userId: true, postId: true }, + }); + + if (!comment) return { error: "Comment not found." }; + if (comment.userId !== session.user.id) + return { error: "You cannot delete this comment." }; + + try { + await prisma.comment.update({ + where: { id: commentId }, + data: { isDeleted: true }, + }); + + revalidatePath(`/posts/${comment.postId}`); + return { success: "Comment deleted." }; + } catch { + return { error: "Something went wrong." }; + } +} + +export async function voteCommentAction( + commentId: string, + voteValue: 1 | -1 +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in to vote." }; + + const comment = await prisma.comment.findUnique({ + where: { id: commentId, isDeleted: false }, + select: { id: true, postId: true }, + }); + if (!comment) return { error: "Comment not found." }; + + const existing = await prisma.commentVote.findUnique({ + where: { userId_commentId: { userId: session.user.id, commentId } }, + }); + + try { + if (existing) { + if (existing.voteValue === voteValue) { + // Toggle off — remove vote + await prisma.$transaction([ + prisma.commentVote.delete({ + where: { + userId_commentId: { userId: session.user.id, commentId }, + }, + }), + prisma.comment.update({ + where: { id: commentId }, + data: { + upvotes: voteValue === 1 ? { decrement: 1 } : undefined, + downvotes: voteValue === -1 ? { decrement: 1 } : undefined, + }, + }), + ]); + } else { + // Change vote direction + await prisma.$transaction([ + prisma.commentVote.update({ + where: { + userId_commentId: { userId: session.user.id, commentId }, + }, + data: { voteValue }, + }), + prisma.comment.update({ + where: { id: commentId }, + data: { + upvotes: voteValue === 1 ? { increment: 1 } : { decrement: 1 }, + downvotes: + voteValue === -1 ? { increment: 1 } : { decrement: 1 }, + }, + }), + ]); + } + } else { + // New vote + await prisma.$transaction([ + prisma.commentVote.create({ + data: { userId: session.user.id, commentId, voteValue }, + }), + prisma.comment.update({ + where: { id: commentId }, + data: { + upvotes: voteValue === 1 ? { increment: 1 } : undefined, + downvotes: voteValue === -1 ? { increment: 1 } : undefined, + }, + }), + ]); + } + + revalidatePath(`/posts/${comment.postId}`); + return { success: "Vote recorded." }; + } catch { + return { error: "Something went wrong." }; + } +} + +export async function saveCommentAction( + commentId: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in to save." }; + + const comment = await prisma.comment.findUnique({ + where: { id: commentId, isDeleted: false }, + select: { id: true, postId: true }, + }); + if (!comment) return { error: "Comment not found." }; + + const existing = await prisma.savedComment.findUnique({ + where: { userId_commentId: { userId: session.user.id, commentId } }, + }); + + try { + if (existing) { + await prisma.savedComment.delete({ + where: { userId_commentId: { userId: session.user.id, commentId } }, + }); + return { success: "Comment unsaved.", saved: false }; + } else { + await prisma.savedComment.create({ + data: { userId: session.user.id, commentId }, + }); + return { success: "Comment saved.", saved: true }; + } + } catch { + return { error: "Something went wrong." }; + } +} + +export async function reportCommentAction( + commentId: string, + reason: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in to report." }; + + const comment = await prisma.comment.findUnique({ + where: { id: commentId, isDeleted: false }, + select: { + id: true, + postId: true, + post: { select: { communityId: true } }, + }, + }); + if (!comment) return { error: "Comment not found." }; + + try { + await prisma.report.create({ + data: { + reporterId: session.user.id, + communityId: comment.post.communityId, + commentId, + customReason: reason.trim() || null, + }, + }); + return { success: "Report submitted. Thank you." }; + } catch { + return { error: "Something went wrong." }; + } +} From 73d7b3a0c6f1f81d96deefcb15ed4663d6feee7c Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sat, 2 May 2026 00:55:29 +0300 Subject: [PATCH 02/10] feat #52: add post page server component at /posts/[id] --- src/app/(main)/posts/[id]/page.tsx | 113 +++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/app/(main)/posts/[id]/page.tsx diff --git a/src/app/(main)/posts/[id]/page.tsx b/src/app/(main)/posts/[id]/page.tsx new file mode 100644 index 0000000..7b07bd6 --- /dev/null +++ b/src/app/(main)/posts/[id]/page.tsx @@ -0,0 +1,113 @@ +import { notFound } from "next/navigation"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { PostStatus } from "@/generated/prisma/client"; +import { PostPageClient } from "@/components/post-page-client"; + +export const dynamic = "force-dynamic"; + +type Params = Promise<{ id: string }>; + +export default async function PostPage({ params }: { params: Params }) { + const { id } = await params; + const session = await auth(); + const currentUserId = session?.user?.id ?? ""; + + const [post, blockedUserIds] = await Promise.all([ + prisma.post.findUnique({ + where: { id, isDeleted: false, status: PostStatus.PUBLISHED }, + select: { + id: true, + title: true, + body: true, + isPinned: true, + upvotes: true, + downvotes: true, + createdAt: true, + userId: true, + user: { + select: { id: true, username: true, name: true, image: true }, + }, + community: { select: { id: true, name: true } }, + _count: { + select: { comments: { where: { isDeleted: false } } }, + }, + }, + }), + currentUserId + ? prisma.userBlock + .findMany({ + where: { blockerId: currentUserId }, + select: { blockedId: true }, + }) + .then((rows) => rows.map((r) => r.blockedId)) + : Promise.resolve([] as string[]), + ]); + + if (!post) notFound(); + + const rawComments = await prisma.comment.findMany({ + where: { postId: post.id }, + select: { + id: true, + body: true, + isPinned: true, + isDeleted: true, + upvotes: true, + downvotes: true, + createdAt: true, + parentCommentId: true, + userId: true, + user: { + select: { id: true, username: true, name: true, image: true }, + }, + votes: { + where: { userId: currentUserId || "00000000-0000-0000-0000-000000000000" }, + select: { voteValue: true }, + }, + saves: { + where: { userId: currentUserId || "00000000-0000-0000-0000-000000000000" }, + select: { savedAt: true }, + }, + }, + orderBy: [{ isPinned: "desc" }, { createdAt: "asc" }], + }); + + const serializedPost = { + id: post.id, + title: post.title, + body: post.body, + isPinned: post.isPinned, + upvotes: post.upvotes, + downvotes: post.downvotes, + createdAt: post.createdAt.toISOString(), + authorId: post.userId, + authorHandle: post.user.username ?? post.user.name ?? "deleted", + communityName: post.community.name, + commentCount: post._count.comments, + }; + + const serializedComments = rawComments.map((c) => ({ + id: c.id, + body: c.isDeleted ? null : c.body, + isPinned: c.isPinned, + isDeleted: c.isDeleted, + upvotes: c.upvotes, + downvotes: c.downvotes, + createdAt: c.createdAt.toISOString(), + parentCommentId: c.parentCommentId, + authorId: c.userId, + authorHandle: c.user.username ?? c.user.name ?? "deleted", + myVote: c.votes[0]?.voteValue ?? null, + isSaved: c.saves.length > 0, + isHidden: blockedUserIds.includes(c.userId), + })); + + return ( + + ); +} From 1121fa50f009134a78c9ebcd8d81dfee1a6d8c44 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sat, 2 May 2026 00:55:39 +0300 Subject: [PATCH 03/10] feat #52: add post page client with threaded comments and auth gate --- src/components/post-page-client.tsx | 760 ++++++++++++++++++++++++++++ 1 file changed, 760 insertions(+) create mode 100644 src/components/post-page-client.tsx diff --git a/src/components/post-page-client.tsx b/src/components/post-page-client.tsx new file mode 100644 index 0000000..621f1dd --- /dev/null +++ b/src/components/post-page-client.tsx @@ -0,0 +1,760 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + Bookmark, + BookmarkCheck, + ChevronDown, + ChevronUp, + Ellipsis, + Flag, + MessageSquare, + Pin, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + createCommentAction, + deleteCommentAction, + editCommentAction, + reportCommentAction, + saveCommentAction, + voteCommentAction, +} from "@/actions/comments"; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +type PostData = { + id: string; + title: string; + body: string | null; + isPinned: boolean; + upvotes: number; + downvotes: number; + createdAt: string; + authorId: string; + authorHandle: string; + communityName: string; + commentCount: number; +}; + +type CommentData = { + id: string; + body: string | null; + isPinned: boolean; + isDeleted: boolean; + upvotes: number; + downvotes: number; + createdAt: string; + parentCommentId: string | null; + authorId: string; + authorHandle: string; + myVote: number | null; + isSaved: boolean; + isHidden: boolean; +}; + +type CommentNode = CommentData & { replies: CommentNode[] }; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function formatRelativeDate(dateString: string) { + const date = new Date(dateString); + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + return new Intl.DateTimeFormat("en", { + day: "numeric", + month: "short", + year: "numeric", + }).format(date); +} + +function buildTree(comments: CommentData[]): CommentNode[] { + const map = new Map(); + for (const c of comments) map.set(c.id, { ...c, replies: [] }); + + const roots: CommentNode[] = []; + for (const c of comments) { + const node = map.get(c.id)!; + if (!c.parentCommentId) { + roots.push(node); + } else { + const parent = map.get(c.parentCommentId); + if (parent) parent.replies.push(node); + else roots.push(node); // orphaned + } + } + return roots; +} + +// ─── Auth Modal ──────────────────────────────────────────────────────────────── + +function AuthModal({ onClose }: { onClose: () => void }) { + return ( +
+
e.stopPropagation()} + > +

+ Join the conversation +

+

+ Log in or create an account to vote, comment, save, and more. +

+
+ + +
+
+
+ ); +} + +// ─── Comment Composer ────────────────────────────────────────────────────────── + +function CommentComposer({ + postId, + currentUserId, + onGuestAction, + parentCommentId, + onSuccess, + autoFocus, +}: { + postId: string; + currentUserId: string | null; + onGuestAction: () => void; + parentCommentId?: string; + onSuccess?: () => void; + autoFocus?: boolean; +}) { + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + if (!currentUserId) { + return ( +
+ Log in to comment… +
+ ); + } + + function handleSubmit() { + if (!body.trim()) return; + setError(null); + + startTransition(async () => { + const result = await createCommentAction( + postId, + body.trim(), + parentCommentId + ); + if (result.error) { + setError(result.error); + return; + } + setBody(""); + onSuccess?.(); + }); + } + + return ( +
+