From 730ce73a7e99d3df2af2b31c2400ef223a0fc43f Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:36:16 +0300 Subject: [PATCH 1/8] feat(schema) #52: add postKarma and commentKarma to User model Adds two dedicated karma counters on the User model. The legacy karmaScore field is kept for compatibility. DB pushed via prisma db push. --- prisma/schema.prisma | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebb1fbc..6b8b4ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,7 +72,9 @@ model User { emailVerified DateTime? image String? passwordHash String? - karmaScore Int @default(0) // Requires Postgres Trigger or $transaction to sync + karmaScore Int @default(0) // Kept for legacy; use postKarma + commentKarma for display + postKarma Int @default(0) + commentKarma Int @default(0) bannerImageUrl String? createdAt DateTime @default(now()) From b3e79b1c0c61dc4285bbc72bde3835baee465497 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:36:53 +0300 Subject: [PATCH 2/8] feat(utils) #57: add formatKarma and slugify helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatKarma: 999→"999", 1.2k, 15.4k, 1.5m with negative support. slugify: converts post title to URL-safe underscore-separated slug. --- src/lib/utils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7e43376..25042c7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,6 +5,18 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +/** + * Formats a karma number for display. + * 999 → "999" | 1250 → "1.2k" | 15400 → "15.4k" | 1500000 → "1.5m" + */ +export function formatKarma(n: number): string { + const sign = n < 0 ? "-" : ""; + const abs = Math.abs(n); + if (abs < 1_000) return String(n); + if (abs < 1_000_000) return `${sign}${Math.floor(abs / 100) / 10}k`; + return `${sign}${Math.floor(abs / 100_000) / 10}m`; +} + export function slugify(text: string): string { const slug = text .toLowerCase() From d0ebeb983a1bf9cdcda6627440d864ca462f5734 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:37:48 +0300 Subject: [PATCH 3/8] feat #57: add votePostAction with karma tracking; author self-vote on create createPostAction now opens an interactive tx to write the initial PostVote record (upvotes:1). votePostAction handles toggle-off, direction-change, and new votes; updates postKarma on the author for non-self-votes only (AC #58). --- src/actions/posts.ts | 176 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 6 deletions(-) diff --git a/src/actions/posts.ts b/src/actions/posts.ts index 83d2ea0..6536e2c 100644 --- a/src/actions/posts.ts +++ b/src/actions/posts.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { auth } from "@/auth"; import { prisma } from "@/lib/prisma"; import { PostStatus } from "@/generated/prisma/client"; +import { slugify } from "@/lib/utils"; const CreatePostSchema = z.object({ title: z.string().trim().min(3, "Title must be at least 3 characters.").max(120, "Title is too long."), @@ -71,8 +72,10 @@ export async function createPostAction( try { const community = await getOrCreateDemoCommunity(session.user.id); - await prisma.$transaction([ - prisma.communityMember.upsert({ + // Use interactive transaction so we can reference the new post's id for self-vote. + // Self-vote does NOT count toward post karma (see AC #58). + await prisma.$transaction(async (tx) => { + await tx.communityMember.upsert({ where: { userId_communityId: { userId: session.user.id, @@ -84,17 +87,28 @@ export async function createPostAction( userId: session.user.id, communityId: community.id, }, - }), - prisma.post.create({ + }); + + const post = await tx.post.create({ data: { title, body: body || null, status: PostStatus.PUBLISHED, communityId: community.id, userId: session.user.id, + upvotes: 1, // author self-vote }, - }), - ]); + select: { id: true }, + }); + + await tx.postVote.create({ + data: { + userId: session.user.id, + postId: post.id, + voteValue: 1, + }, + }); + }); revalidatePath("/"); @@ -202,3 +216,153 @@ export async function deletePostAction(formData: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) return { error: "You must be logged in to vote." }; + + const post = await prisma.post.findUnique({ + where: { id: postId, isDeleted: false, status: PostStatus.PUBLISHED }, + select: { + id: true, + userId: true, + title: true, + community: { select: { name: true } }, + }, + }); + if (!post) return { error: "Post not found." }; + + // Self-votes never affect karma (AC #58) + const isSelfVote = post.userId === session.user.id; + + const existing = await prisma.postVote.findUnique({ + where: { userId_postId: { userId: session.user.id, postId } }, + }); + + try { + if (existing) { + if (existing.voteValue === voteValue) { + // Toggle off — remove vote + if (isSelfVote) { + await prisma.$transaction([ + prisma.postVote.delete({ + where: { userId_postId: { userId: session.user.id, postId } }, + }), + prisma.post.update({ + where: { id: postId }, + data: { + upvotes: voteValue === 1 ? { decrement: 1 } : undefined, + downvotes: voteValue === -1 ? { decrement: 1 } : undefined, + }, + }), + ]); + } else { + await prisma.$transaction([ + prisma.postVote.delete({ + where: { userId_postId: { userId: session.user.id, postId } }, + }), + prisma.post.update({ + where: { id: postId }, + data: { + upvotes: voteValue === 1 ? { decrement: 1 } : undefined, + downvotes: voteValue === -1 ? { decrement: 1 } : undefined, + }, + }), + prisma.user.update({ + where: { id: post.userId }, + data: { + postKarma: voteValue === 1 ? { decrement: 1 } : { increment: 1 }, + }, + }), + ]); + } + } else { + // Change direction (e.g. upvote → downvote) + // Net karma delta = newValue - oldValue (±2) + if (isSelfVote) { + await prisma.$transaction([ + prisma.postVote.update({ + where: { userId_postId: { userId: session.user.id, postId } }, + data: { voteValue }, + }), + prisma.post.update({ + where: { id: postId }, + data: { + upvotes: voteValue === 1 ? { increment: 1 } : { decrement: 1 }, + downvotes: voteValue === -1 ? { increment: 1 } : { decrement: 1 }, + }, + }), + ]); + } else { + await prisma.$transaction([ + prisma.postVote.update({ + where: { userId_postId: { userId: session.user.id, postId } }, + data: { voteValue }, + }), + prisma.post.update({ + where: { id: postId }, + data: { + upvotes: voteValue === 1 ? { increment: 1 } : { decrement: 1 }, + downvotes: voteValue === -1 ? { increment: 1 } : { decrement: 1 }, + }, + }), + prisma.user.update({ + where: { id: post.userId }, + data: { + // Was -1 → now +1: delta +2 | Was +1 → now -1: delta -2 + postKarma: voteValue === 1 ? { increment: 2 } : { decrement: 2 }, + }, + }), + ]); + } + } + } else { + // New vote + if (isSelfVote) { + await prisma.$transaction([ + prisma.postVote.create({ + data: { userId: session.user.id, postId, voteValue }, + }), + prisma.post.update({ + where: { id: postId }, + data: { + upvotes: voteValue === 1 ? { increment: 1 } : undefined, + downvotes: voteValue === -1 ? { increment: 1 } : undefined, + }, + }), + ]); + } else { + await prisma.$transaction([ + prisma.postVote.create({ + data: { userId: session.user.id, postId, voteValue }, + }), + prisma.post.update({ + where: { id: postId }, + data: { + upvotes: voteValue === 1 ? { increment: 1 } : undefined, + downvotes: voteValue === -1 ? { increment: 1 } : undefined, + }, + }), + prisma.user.update({ + where: { id: post.userId }, + data: { + postKarma: voteValue === 1 ? { increment: 1 } : { decrement: 1 }, + }, + }), + ]); + } + } + + revalidatePath(`/communities/${post.community.name}`); + revalidatePath( + `/communities/${post.community.name}/comments/${postId}/${slugify(post.title)}` + ); + return { success: "Vote recorded." }; + } catch (error) { + console.error("votePostAction failed", error); + return { error: "Something went wrong." }; + } +} From 2a855de2361c967989feeec868633370761510a7 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:38:40 +0300 Subject: [PATCH 4/8] feat #57: add voteCommentAction karma tracking; author self-vote on create createCommentAction now uses an interactive tx to write the initial CommentVote record (upvotes:1). voteCommentAction updates commentKarma on the author for non-self-votes, mirroring the posts karma logic. --- src/actions/comments.ts | 159 +++++++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 36 deletions(-) diff --git a/src/actions/comments.ts b/src/actions/comments.ts index ffa920a..86b0b15 100644 --- a/src/actions/comments.ts +++ b/src/actions/comments.ts @@ -48,13 +48,27 @@ export async function createCommentAction( } try { - await prisma.comment.create({ - data: { - postId, - userId: session.user.id, - body: validated.data.body, - parentCommentId: parentCommentId ?? null, - }, + // Use interactive transaction so we can reference the new comment's id for self-vote. + // Self-vote does NOT count toward comment karma (see AC #58). + await prisma.$transaction(async (tx) => { + const comment = await tx.comment.create({ + data: { + postId, + userId: session.user.id, + body: validated.data.body, + parentCommentId: parentCommentId ?? null, + upvotes: 1, // author self-vote + }, + select: { id: true }, + }); + + await tx.commentVote.create({ + data: { + userId: session.user.id, + commentId: comment.id, + voteValue: 1, + }, + }); }); revalidatePath( @@ -157,12 +171,16 @@ export async function voteCommentAction( where: { id: commentId, isDeleted: false }, select: { id: true, + userId: true, postId: true, post: { select: { title: true, community: { select: { name: true } } } }, }, }); if (!comment) return { error: "Comment not found." }; + // Self-votes never affect karma (AC #58) + const isSelfVote = comment.userId === session.user.id; + const existing = await prisma.commentVote.findUnique({ where: { userId_commentId: { userId: session.user.id, commentId } }, }); @@ -171,53 +189,122 @@ export async function voteCommentAction( if (existing) { if (existing.voteValue === voteValue) { // Toggle off — remove vote + if (isSelfVote) { + 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 { + 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, + }, + }), + prisma.user.update({ + where: { id: comment.userId }, + data: { + commentKarma: voteValue === 1 ? { decrement: 1 } : { increment: 1 }, + }, + }), + ]); + } + } else { + // Change vote direction (e.g. upvote → downvote) + // Net karma delta = newValue - oldValue (±2) + if (isSelfVote) { + 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 { + 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 }, + }, + }), + prisma.user.update({ + where: { id: comment.userId }, + data: { + // Was -1 → now +1: delta +2 | Was +1 → now -1: delta -2 + commentKarma: voteValue === 1 ? { increment: 2 } : { decrement: 2 }, + }, + }), + ]); + } + } + } else { + // New vote + if (isSelfVote) { await prisma.$transaction([ - prisma.commentVote.delete({ - where: { - userId_commentId: { userId: session.user.id, commentId }, - }, + prisma.commentVote.create({ + data: { userId: session.user.id, commentId, voteValue }, }), prisma.comment.update({ where: { id: commentId }, data: { - upvotes: voteValue === 1 ? { decrement: 1 } : undefined, - downvotes: voteValue === -1 ? { decrement: 1 } : undefined, + upvotes: voteValue === 1 ? { increment: 1 } : undefined, + downvotes: voteValue === -1 ? { increment: 1 } : undefined, }, }), ]); } else { - // Change vote direction await prisma.$transaction([ - prisma.commentVote.update({ - where: { - userId_commentId: { userId: session.user.id, commentId }, - }, - data: { voteValue }, + prisma.commentVote.create({ + data: { userId: session.user.id, commentId, voteValue }, }), prisma.comment.update({ where: { id: commentId }, data: { - upvotes: voteValue === 1 ? { increment: 1 } : { decrement: 1 }, - downvotes: - voteValue === -1 ? { increment: 1 } : { decrement: 1 }, + upvotes: voteValue === 1 ? { increment: 1 } : undefined, + downvotes: voteValue === -1 ? { increment: 1 } : undefined, + }, + }), + prisma.user.update({ + where: { id: comment.userId }, + data: { + commentKarma: 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( From 263960f475c59ca6aa3b23ec83f2ec24f54b4667 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:39:06 +0300 Subject: [PATCH 5/8] feat #57: expose myVote on post and authorKarma on all authors Adds votes sub-select to post query and postKarma+commentKarma to user selects for both the post author and every comment author. Serializes both to the client component. --- .../[name]/comments/[id]/[slug]/page.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/communities/[name]/comments/[id]/[slug]/page.tsx b/src/app/(main)/communities/[name]/comments/[id]/[slug]/page.tsx index 664cf35..4085382 100644 --- a/src/app/(main)/communities/[name]/comments/[id]/[slug]/page.tsx +++ b/src/app/(main)/communities/[name]/comments/[id]/[slug]/page.tsx @@ -28,9 +28,22 @@ export default async function PostPage({ params }: { params: Params }) { createdAt: true, userId: true, user: { - select: { id: true, username: true, name: true, image: true }, + select: { + id: true, + username: true, + name: true, + image: true, + postKarma: true, + commentKarma: true, + }, }, community: { select: { id: true, name: true } }, + votes: { + where: { + userId: currentUserId || "00000000-0000-0000-0000-000000000000", + }, + select: { voteValue: true }, + }, _count: { select: { comments: { where: { isDeleted: false } } }, }, @@ -61,7 +74,14 @@ export default async function PostPage({ params }: { params: Params }) { parentCommentId: true, userId: true, user: { - select: { id: true, username: true, name: true, image: true }, + select: { + id: true, + username: true, + name: true, + image: true, + postKarma: true, + commentKarma: true, + }, }, votes: { where: { @@ -86,9 +106,11 @@ export default async function PostPage({ params }: { params: Params }) { isPinned: post.isPinned, upvotes: post.upvotes, downvotes: post.downvotes, + myVote: (post.votes[0]?.voteValue ?? null) as 1 | -1 | null, createdAt: post.createdAt.toISOString(), authorId: post.userId, authorHandle: post.user.username ?? post.user.name ?? "deleted", + authorKarma: post.user.postKarma + post.user.commentKarma, communityName: post.community.name, commentCount: post._count.comments, }; @@ -104,6 +126,7 @@ export default async function PostPage({ params }: { params: Params }) { parentCommentId: c.parentCommentId, authorId: c.userId, authorHandle: c.user.username ?? c.user.name ?? "deleted", + authorKarma: c.user.postKarma + c.user.commentKarma, myVote: c.votes[0]?.voteValue ?? null, isSaved: c.saves.length > 0, isHidden: blockedUserIds.includes(c.userId), From a17e956dbb3d14bf49170a374b8647ad2c35b780 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:40:40 +0300 Subject: [PATCH 6/8] feat #57: interactive post vote column; author karma tooltips Post vote column now wires to votePostAction with optimistic UI and revert-on-error. Comment and post author names become Links to /u/ with a title tooltip showing formatted karma. Imports formatKarma. --- src/components/post-page-client.tsx | 102 ++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 13 deletions(-) diff --git a/src/components/post-page-client.tsx b/src/components/post-page-client.tsx index 621f1dd..a34a1ff 100644 --- a/src/components/post-page-client.tsx +++ b/src/components/post-page-client.tsx @@ -21,7 +21,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; +import { cn, formatKarma } from "@/lib/utils"; import { createCommentAction, deleteCommentAction, @@ -30,6 +30,7 @@ import { saveCommentAction, voteCommentAction, } from "@/actions/comments"; +import { votePostAction } from "@/actions/posts"; // ─── Types ───────────────────────────────────────────────────────────────────── @@ -40,9 +41,11 @@ type PostData = { isPinned: boolean; upvotes: number; downvotes: number; + myVote: 1 | -1 | null; createdAt: string; authorId: string; authorHandle: string; + authorKarma: number; communityName: string; commentCount: number; }; @@ -58,6 +61,7 @@ type CommentData = { parentCommentId: string | null; authorId: string; authorHandle: string; + authorKarma: number; myVote: number | null; isSaved: boolean; isHidden: boolean; @@ -412,9 +416,13 @@ function CommentThread({ )} {!node.isDeleted && ( - + u/{node.authorHandle} - + )} {formatRelativeDate(node.createdAt)} @@ -623,13 +631,59 @@ export function PostPageClient({ currentUserId: string | null; }) { const [showAuthModal, setShowAuthModal] = useState(false); + const [postMyVote, setPostMyVote] = useState<1 | -1 | null>(post.myVote); + const [postUpvotes, setPostUpvotes] = useState(post.upvotes); + const [postDownvotes, setPostDownvotes] = useState(post.downvotes); + const [isPostVoting, startPostVoteTransition] = useTransition(); + const commentTree = buildTree(comments); - const score = post.upvotes - post.downvotes; + const postScore = postUpvotes - postDownvotes; function onGuestAction() { setShowAuthModal(true); } + function handlePostVote(val: 1 | -1) { + if (!currentUserId) { + onGuestAction(); + return; + } + + const prevVote = postMyVote; + const prevUp = postUpvotes; + const prevDown = postDownvotes; + + // Optimistic update + if (postMyVote === val) { + setPostMyVote(null); + if (val === 1) setPostUpvotes((v) => v - 1); + else setPostDownvotes((v) => v - 1); + } else { + if (postMyVote !== null) { + if (val === 1) { + setPostUpvotes((v) => v + 1); + setPostDownvotes((v) => v - 1); + } else { + setPostDownvotes((v) => v + 1); + setPostUpvotes((v) => v - 1); + } + } else { + if (val === 1) setPostUpvotes((v) => v + 1); + else setPostDownvotes((v) => v + 1); + } + setPostMyVote(val); + } + + startPostVoteTransition(async () => { + const result = await votePostAction(post.id, val); + if (result.error) { + setPostMyVote(prevVote); + setPostUpvotes(prevUp); + setPostDownvotes(prevDown); + } + }); + } + return ( <> {showAuthModal && ( @@ -654,29 +708,45 @@ export function PostPageClient({ {/* Post card */}
- {/* Vote column — decorative, post voting not in scope */} + {/* Vote column */}
0 + postScore > 0 ? "text-primary" - : score < 0 + : postScore < 0 ? "text-destructive" : "text-muted-foreground" )} > - {score} + {postScore} @@ -694,7 +764,13 @@ export function PostPageClient({ c/{post.communityName} - u/{post.authorHandle} + + u/{post.authorHandle} + {formatRelativeDate(post.createdAt)}
From 9075c6e325fbe14ba98acb306f8374a7f555e385 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:41:04 +0300 Subject: [PATCH 7/8] feat #57: interactive post vote buttons with optimistic UI Server component now passes currentUserId and per-post myVote from the DB. CommunityPostCard gains local vote state wired to votePostAction. AuthModal intercepts guest clicks. --- src/app/(main)/communities/[name]/page.tsx | 12 +- .../communities/community-page-client.tsx | 313 ++++++++++++------ 2 files changed, 224 insertions(+), 101 deletions(-) diff --git a/src/app/(main)/communities/[name]/page.tsx b/src/app/(main)/communities/[name]/page.tsx index 8bcaa77..f475898 100644 --- a/src/app/(main)/communities/[name]/page.tsx +++ b/src/app/(main)/communities/[name]/page.tsx @@ -41,6 +41,7 @@ export default async function CommunityPage({ if (!community) notFound(); const session = await auth(); + const currentUserId = session?.user?.id ?? ""; let isMember = false; let canManageSettings = false; @@ -80,8 +81,14 @@ export default async function CommunityPage({ }, orderBy: getPostOrderBy(currentSort), include: { - user: { select: { username: true, name: true } }, + user: { select: { id: true, username: true, name: true } }, flair: { select: { name: true, colorHex: true } }, + votes: { + where: { + userId: currentUserId || "00000000-0000-0000-0000-000000000000", + }, + select: { voteValue: true }, + }, _count: { select: { comments: true } }, }, take: 25, @@ -118,8 +125,10 @@ export default async function CommunityPage({ isPinned: p.isPinned, upvotes: p.upvotes, downvotes: p.downvotes, + myVote: (p.votes[0]?.voteValue ?? null) as 1 | -1 | null, commentCount: p._count.comments, createdAt: p.createdAt.toISOString(), + authorId: p.user.id, authorHandle: p.user.username || p.user.name || "anonymous", flair: p.flair ? { name: p.flair.name, colorHex: p.flair.colorHex } @@ -133,6 +142,7 @@ export default async function CommunityPage({ isMember={isMember} canManageSettings={canManageSettings} currentSort={currentSort} + currentUserId={currentUserId || null} /> ); } diff --git a/src/components/communities/community-page-client.tsx b/src/components/communities/community-page-client.tsx index 1ecdb58..e527507 100644 --- a/src/components/communities/community-page-client.tsx +++ b/src/components/communities/community-page-client.tsx @@ -19,6 +19,7 @@ import { import { Button } from "@/components/ui/button"; import { InviteUserForm } from "@/components/communities/invite-user-form"; import { joinCommunityAction, leaveCommunityAction } from "@/actions/communities"; +import { votePostAction } from "@/actions/posts"; import { cn, slugify } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -37,8 +38,10 @@ type CommunityPost = { isPinned: boolean; upvotes: number; downvotes: number; + myVote: 1 | -1 | null; commentCount: number; createdAt: string; + authorId: string; authorHandle: string; flair: { name: string; colorHex: string | null } | null; }; @@ -65,6 +68,7 @@ type CommunityPageClientProps = { isMember: boolean; canManageSettings: boolean; currentSort: string; + currentUserId: string | null; }; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -85,6 +89,37 @@ function formatRelativeDate(dateString: string) { }).format(date); } +// ─── Auth Modal ──────────────────────────────────────────────────────────────── + +function AuthModal({ onClose }: { onClose: () => void }) { + return ( +
+
e.stopPropagation()} + > +

+ Join the conversation +

+

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

+
+ + +
+
+
+ ); +} + // ─── Sort tabs ──────────────────────────────────────────────────────────────── const SORT_OPTIONS = [ @@ -131,13 +166,63 @@ function SortTabs({ function CommunityPostCard({ post, communityName, + currentUserId, + onGuestAction, }: { post: CommunityPost; communityName: string; + currentUserId: string | null; + onGuestAction: () => void; }) { - const score = post.upvotes - post.downvotes; + const [myVote, setMyVote] = useState<1 | -1 | null>(post.myVote); + const [upvotes, setUpvotes] = useState(post.upvotes); + const [downvotes, setDownvotes] = useState(post.downvotes); + const [isVoting, startVoteTransition] = useTransition(); + + const score = upvotes - downvotes; const postUrl = `/communities/${communityName}/comments/${post.id}/${slugify(post.title)}`; + function handleVote(val: 1 | -1) { + if (!currentUserId) { + onGuestAction(); + return; + } + + const prevVote = myVote; + const prevUp = upvotes; + const prevDown = downvotes; + + // Optimistic update + if (myVote === val) { + setMyVote(null); + if (val === 1) setUpvotes((v) => v - 1); + else setDownvotes((v) => v - 1); + } else { + if (myVote !== null) { + if (val === 1) { + setUpvotes((v) => v + 1); + setDownvotes((v) => v - 1); + } else { + setDownvotes((v) => v + 1); + setUpvotes((v) => v - 1); + } + } else { + if (val === 1) setUpvotes((v) => v + 1); + else setDownvotes((v) => v + 1); + } + setMyVote(val); + } + + startVoteTransition(async () => { + const result = await votePostAction(post.id, val); + if (result.error) { + setMyVote(prevVote); + setUpvotes(prevUp); + setDownvotes(prevDown); + } + }); + } + return (
@@ -166,8 +259,16 @@ function CommunityPostCard({ {score} @@ -351,10 +452,12 @@ export function CommunityPageClient({ isMember, canManageSettings, currentSort, + currentUserId, }: CommunityPageClientProps) { const { data: session } = useSession(); const [memberState, setMemberState] = useState(isMember); const [actionMessage, setActionMessage] = useState(null); + const [showAuthModal, setShowAuthModal] = useState(false); const [isPending, startTransition] = useTransition(); const isOwner = session?.user?.id === community.ownerId; @@ -380,127 +483,137 @@ export function CommunityPageClient({ const regularPosts = posts.filter((p) => !p.isPinned); return ( -
- {/* ── Community banner + header ── */} -
- -
-
-
- {/* Community identity */} -
-
-

- c/{community.name} -

- {community.isNsfw && ( - - - NSFW + <> + {showAuthModal && ( + setShowAuthModal(false)} /> + )} + +
+ {/* ── Community banner + header ── */} +
+ +
+
+
+ {/* Community identity */} +
+
+

+ c/{community.name} +

+ {community.isNsfw && ( + + + NSFW + + )} +
+
+ + + {community.memberCount.toLocaleString()}{" "} + {community.memberCount === 1 ? "member" : "members"} +
+ {community.description && ( +

+ {community.description} +

+ )} + {actionMessage && ( +

{actionMessage}

)}
-
- - - {community.memberCount.toLocaleString()}{" "} - {community.memberCount === 1 ? "member" : "members"} - -
- {community.description && ( -

- {community.description} -

- )} - {actionMessage && ( -

{actionMessage}

- )} -
- {/* Join / Leave */} - {session?.user ? ( - !isOwner && ( - + ) + ) : ( + - ) - ) : ( - - )} + )} +
-
- {/* ── Main content ── */} -
-
- {/* ── Feed column ── */} -
- {/* Sort tabs */} - - - {/* Pinned posts */} - {pinnedPosts.map((post) => ( - - ))} + {/* ── Main content ── */} +
+
+ {/* ── Feed column ── */} +
+ {/* Sort tabs */} + - {/* Regular posts */} - {regularPosts.length > 0 ? ( - regularPosts.map((post) => ( + {/* Pinned posts */} + {pinnedPosts.map((post) => ( setShowAuthModal(true)} /> - )) - ) : pinnedPosts.length === 0 ? ( -
- -

No posts yet — be the first to share something.

-
- ) : null} + ))} + + {/* Regular posts */} + {regularPosts.length > 0 ? ( + regularPosts.map((post) => ( + setShowAuthModal(true)} + /> + )) + ) : pinnedPosts.length === 0 ? ( +
+ +

No posts yet — be the first to share something.

+
+ ) : null} - {/* Invite form — members only */} - {session?.user && memberState && ( - - )} -
+ {/* Invite form — members only */} + {session?.user && memberState && ( + + )} +
+ + {/* ── Info panel ── */} + - {/* ── Info panel ── */} - - - {/* Mobile info panel (below feed) */} -
-
-
+ ); } From 196ad9d5d7739febf2629df62d6e43616d32fdb3 Mon Sep 17 00:00:00 2001 From: Samet Ekin Polat Date: Sun, 3 May 2026 14:41:28 +0300 Subject: [PATCH 8/8] feat #57 #55: add /u/[username] profile page with karma breakdown Server component fetches user by username and passes postKarma / commentKarma to UserProfileClient. Client shows total karma (formatted) with a collapsible breakdown of Post Karma vs Comment Karma. --- src/app/(main)/u/[username]/page.tsx | 41 +++++++ src/components/user-profile-client.tsx | 156 +++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/app/(main)/u/[username]/page.tsx create mode 100644 src/components/user-profile-client.tsx diff --git a/src/app/(main)/u/[username]/page.tsx b/src/app/(main)/u/[username]/page.tsx new file mode 100644 index 0000000..891a8ca --- /dev/null +++ b/src/app/(main)/u/[username]/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { UserProfileClient } from "@/components/user-profile-client"; + +export const dynamic = "force-dynamic"; + +type Params = Promise<{ username: string }>; + +export async function generateMetadata({ params }: { params: Params }) { + const { username } = await params; + return { title: `u/${username}` }; +} + +export default async function UserProfilePage({ params }: { params: Params }) { + const { username } = await params; + + const user = await prisma.user.findFirst({ + where: { username }, + select: { + id: true, + username: true, + name: true, + image: true, + postKarma: true, + commentKarma: true, + createdAt: true, + }, + }); + + if (!user) notFound(); + + return ( + + ); +} diff --git a/src/components/user-profile-client.tsx b/src/components/user-profile-client.tsx new file mode 100644 index 0000000..06099a1 --- /dev/null +++ b/src/components/user-profile-client.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronUp, MessageSquare, FileText, Calendar } from "lucide-react"; +import { formatKarma } from "@/lib/utils"; + +type UserProfileClientProps = { + username: string; + image: string | null; + postKarma: number; + commentKarma: number; + createdAt: string; +}; + +export function UserProfileClient({ + username, + image, + postKarma, + commentKarma, + createdAt, +}: UserProfileClientProps) { + const [showBreakdown, setShowBreakdown] = useState(false); + + const totalKarma = postKarma + commentKarma; + + const joinDate = new Intl.DateTimeFormat("en", { + month: "long", + day: "numeric", + year: "numeric", + }).format(new Date(createdAt)); + + return ( +
+
+ {/* Profile card */} +
+ {/* Banner */} +
+ + {/* Avatar + basic info */} +
+
+
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + {username} + ) : ( + username[0]?.toUpperCase() ?? "U" + )} +
+
+ +

u/{username}

+ +
+ + Joined {joinDate} +
+
+
+ + {/* Karma card */} +
+
+

Karma

+
+ +
+ {/* Total karma row */} + + + {/* Breakdown */} + {showBreakdown && ( +
+ {/* Post karma */} +
+
+
+ +
+ Post Karma +
+ = 0 + ? "text-sm font-semibold text-foreground" + : "text-sm font-semibold text-destructive" + } + > + {formatKarma(postKarma)} + +
+ + {/* Comment karma */} +
+
+
+ +
+ Comment Karma +
+ = 0 + ? "text-sm font-semibold text-foreground" + : "text-sm font-semibold text-destructive" + } + > + {formatKarma(commentKarma)} + +
+
+ )} +
+
+
+
+ ); +}