From d3bc512690cb9e1ed971645802beb338a7de0478 Mon Sep 17 00:00:00 2001 From: Yentec Date: Mon, 18 May 2026 20:50:28 +0200 Subject: [PATCH 1/9] feat(votes,comments): add zod validators + remove node engine version in package --- lib/validators/comments.ts | 12 ++++++++++++ lib/validators/votes.ts | 7 +++++++ package.json | 5 +---- 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 lib/validators/comments.ts create mode 100644 lib/validators/votes.ts diff --git a/lib/validators/comments.ts b/lib/validators/comments.ts new file mode 100644 index 0000000..07eb7aa --- /dev/null +++ b/lib/validators/comments.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const createCommentSchema = z.object({ + postId: z.cuid2(), + content: z.string().trim().min(1, "Comment cannot be empty").max(1000), +}); + +export const deleteCommentSchema = z.object({ + commentId: z.cuid2(), +}); + +export type CreateCommentInput = z.infer; diff --git a/lib/validators/votes.ts b/lib/validators/votes.ts new file mode 100644 index 0000000..7d9f765 --- /dev/null +++ b/lib/validators/votes.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const toggleVoteSchema = z.object({ + postId: z.cuid2(), +}); + +export type ToggleVoteInput = z.infer; diff --git a/package.json b/package.json index 88ee056..3c8434b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,7 @@ { "name": "feedbackflow", - "version": "0.5.0", + "version": "0.5.1", "private": true, - "engines": { - "node": ">=20.9.0" - }, "scripts": { "dev": "next dev", "build": "next build", From 5c634298299dd13dd4d3ba9ada06e2fde7240700 Mon Sep 17 00:00:00 2001 From: Yentec Date: Mon, 18 May 2026 20:51:46 +0200 Subject: [PATCH 2/9] feat(votes): add toggleVote server action --- package.json | 2 +- server/actions/votes.ts | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 server/actions/votes.ts diff --git a/package.json b/package.json index 3c8434b..481712c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feedbackflow", - "version": "0.5.1", + "version": "0.5.2", "private": true, "scripts": { "dev": "next dev", diff --git a/server/actions/votes.ts b/server/actions/votes.ts new file mode 100644 index 0000000..ad2b2d7 --- /dev/null +++ b/server/actions/votes.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { toggleVoteSchema, type ToggleVoteInput } from "@/lib/validators/votes"; + +type ActionResult = { ok: true; data: T } | { ok: false; error: string }; + +export async function toggleVote( + input: ToggleVoteInput, +): Promise> { + const parsed = toggleVoteSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Invalid input" }; + + const session = await auth(); + if (!session?.user?.id) return { ok: false, error: "Unauthorized" }; + + const post = await db.post.findUnique({ + where: { id: parsed.data.postId }, + select: { id: true, board: { select: { slug: true, isPublic: true } } }, + }); + if (!post || !post.board.isPublic) return { ok: false, error: "Post not found" }; + + const existing = await db.vote.findUnique({ + where: { postId_userId: { postId: post.id, userId: session.user.id } }, + select: { id: true }, + }); + + if (existing) { + await db.vote.delete({ where: { id: existing.id } }); + } else { + await db.vote.create({ + data: { postId: post.id, userId: session.user.id }, + }); + } + + const voteCount = await db.vote.count({ where: { postId: post.id } }); + + revalidatePath(`/b/${post.board.slug}`); + revalidatePath(`/b/${post.board.slug}/posts/${post.id}`); + + return { ok: true, data: { hasVoted: !existing, voteCount } }; +} From 6ad75410850980ad80c9067fc1b8ab6cbfdd4c01 Mon Sep 17 00:00:00 2001 From: Yentec Date: Mon, 18 May 2026 20:56:00 +0200 Subject: [PATCH 3/9] feat(votes): wire optimistic vote button into post cards --- app/b/[slug]/page.tsx | 2 +- components/posts/post-card.tsx | 27 +++++------- components/posts/vote-button.tsx | 72 ++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 components/posts/vote-button.tsx diff --git a/app/b/[slug]/page.tsx b/app/b/[slug]/page.tsx index c8bdf13..b1a49fb 100644 --- a/app/b/[slug]/page.tsx +++ b/app/b/[slug]/page.tsx @@ -64,7 +64,7 @@ export default async function PublicBoardPage({ params, searchParams }: Props) { ) : (
{posts.map((post: PublicPost) => ( - + ))}
)} diff --git a/components/posts/post-card.tsx b/components/posts/post-card.tsx index 8548f85..1f74239 100644 --- a/components/posts/post-card.tsx +++ b/components/posts/post-card.tsx @@ -1,27 +1,22 @@ import Link from "next/link"; -import { MessageSquare, ChevronUp } from "lucide-react"; +import { MessageSquare } from "lucide-react"; import { StatusBadge } from "./status-badge"; +import { VoteButton } from "./vote-button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { cn } from "@/lib/utils"; import type { PublicPost } from "@/server/queries/posts"; -type Props = { post: PublicPost; slug: string }; +type Props = { post: PublicPost; slug: string; authenticated: boolean }; -export function PostCard({ post, slug }: Props) { +export function PostCard({ post, slug, authenticated }: Props) { return (
-
- - {post.voteCount} -
+
diff --git a/components/posts/vote-button.tsx b/components/posts/vote-button.tsx new file mode 100644 index 0000000..d966921 --- /dev/null +++ b/components/posts/vote-button.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useOptimistic, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ChevronUp } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toggleVote } from "@/server/actions/votes"; + +type Props = { + postId: string; + voteCount: number; + hasVoted: boolean; + authenticated: boolean; + boardSlug: string; + size?: "sm" | "md"; +}; + +export function VoteButton({ + postId, + voteCount, + hasVoted, + authenticated, + boardSlug, + size = "sm", +}: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const [optimistic, setOptimistic] = useOptimistic( + { voteCount, hasVoted }, + (state, nextHasVoted: boolean) => ({ + voteCount: state.voteCount + (nextHasVoted ? 1 : -1), + hasVoted: nextHasVoted, + }), + ); + + function handleClick() { + if (!authenticated) { + router.push(`/login?callbackUrl=/b/${boardSlug}`); + return; + } + startTransition(async () => { + setOptimistic(!optimistic.hasVoted); + const result = await toggleVote({ postId }); + if (!result.ok) { + toast.error(result.error); + } + }); + } + + return ( + + ); +} diff --git a/package.json b/package.json index 481712c..5076394 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feedbackflow", - "version": "0.5.2", + "version": "0.5.3", "private": true, "scripts": { "dev": "next dev", From 6226f0679deffeeea578e3409a70822b04a92e7b Mon Sep 17 00:00:00 2001 From: Yentec Date: Mon, 18 May 2026 20:57:49 +0200 Subject: [PATCH 4/9] feat(comments): add comment server actions with authorization --- package.json | 2 +- server/actions/comments.ts | 77 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 server/actions/comments.ts diff --git a/package.json b/package.json index 5076394..3c03db5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feedbackflow", - "version": "0.5.3", + "version": "0.5.4", "private": true, "scripts": { "dev": "next dev", diff --git a/server/actions/comments.ts b/server/actions/comments.ts new file mode 100644 index 0000000..2f6cfd9 --- /dev/null +++ b/server/actions/comments.ts @@ -0,0 +1,77 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { + createCommentSchema, + deleteCommentSchema, + type CreateCommentInput, +} from "@/lib/validators/comments"; +import type { z } from "zod"; + +type ActionResult = + | { ok: true; data: T } + | { ok: false; error: string; issues?: Record }; + +export async function createComment( + input: CreateCommentInput, +): Promise> { + const parsed = createCommentSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: "Invalid input", + issues: parsed.error.flatten().fieldErrors, + }; + } + + const session = await auth(); + if (!session?.user?.id) return { ok: false, error: "Unauthorized" }; + + const post = await db.post.findUnique({ + where: { id: parsed.data.postId }, + select: { id: true, board: { select: { slug: true, isPublic: true } } }, + }); + if (!post || !post.board.isPublic) return { ok: false, error: "Post not found" }; + + const comment = await db.comment.create({ + data: { + content: parsed.data.content, + postId: post.id, + authorId: session.user.id, + }, + select: { id: true }, + }); + + revalidatePath(`/b/${post.board.slug}/posts/${post.id}`); + return { ok: true, data: { commentId: comment.id } }; +} + +export async function deleteComment( + input: z.infer, +): Promise { + const parsed = deleteCommentSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Invalid input" }; + + const session = await auth(); + if (!session?.user?.id) return { ok: false, error: "Unauthorized" }; + + const comment = await db.comment.findUnique({ + where: { id: parsed.data.commentId }, + select: { + authorId: true, + post: { select: { id: true, board: { select: { slug: true, ownerId: true } } } }, + }, + }); + if (!comment) return { ok: false, error: "Comment not found" }; + + const isAuthor = comment.authorId === session.user.id; + const isBoardOwner = comment.post.board.ownerId === session.user.id; + if (!isAuthor && !isBoardOwner) return { ok: false, error: "Forbidden" }; + + await db.comment.delete({ where: { id: parsed.data.commentId } }); + + revalidatePath(`/b/${comment.post.board.slug}/posts/${comment.post.id}`); + return { ok: true, data: undefined }; +} From 054253c55b40999610f5a7dd6ddca835c14ef79d Mon Sep 17 00:00:00 2001 From: Yentec Date: Mon, 18 May 2026 21:01:01 +0200 Subject: [PATCH 5/9] feat(posts): add post detail page with comments --- app/b/[slug]/posts/[id]/page.tsx | 101 ++++++++++++++++++++++++++++++ components/posts/comment-form.tsx | 69 ++++++++++++++++++++ components/posts/comment-item.tsx | 65 +++++++++++++++++++ package.json | 2 +- server/queries/comments.ts | 17 +++++ 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 app/b/[slug]/posts/[id]/page.tsx create mode 100644 components/posts/comment-form.tsx create mode 100644 components/posts/comment-item.tsx create mode 100644 server/queries/comments.ts diff --git a/app/b/[slug]/posts/[id]/page.tsx b/app/b/[slug]/posts/[id]/page.tsx new file mode 100644 index 0000000..3fc95bc --- /dev/null +++ b/app/b/[slug]/posts/[id]/page.tsx @@ -0,0 +1,101 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { auth } from "@/auth"; +import { getPostById } from "@/server/queries/posts"; +import { listPostComments, type PublicComment } from "@/server/queries/comments"; +import { StatusBadge } from "@/components/posts/status-badge"; +import { VoteButton } from "@/components/posts/vote-button"; +import { CommentForm } from "@/components/posts/comment-form"; +import { CommentItem } from "@/components/posts/comment-item"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +type Props = { params: Promise<{ slug: string; id: string }> }; + +export async function generateMetadata({ params }: Props) { + const { id } = await params; + const post = await getPostById(id); + if (!post) return { title: "Not found" }; + return { title: post.title, description: post.content.slice(0, 160) }; +} + +export default async function PostDetailPage({ params }: Props) { + const { slug, id } = await params; + const post = await getPostById(id); + if (!post || post.board.slug !== slug || !post.board.isPublic) notFound(); + + const session = await auth(); + const comments = await listPostComments(id); + const isBoardOwner = session?.user?.id === post.board.ownerId; + + return ( +
+ + Back to {post.board.name} + + +
+ + +
+
+

{post.title}

+ +
+ +
+ {post.author && ( + <> + + + + {(post.author.name ?? "?").slice(0, 1).toUpperCase()} + + + {post.author.name ?? "Anonymous"} + · + + )} + +
+ +

{post.content}

+
+
+ +
+

+ Comments ({comments.length}) +

+ + + +
+ {comments.length === 0 ? ( +

No comments yet.

+ ) : ( + comments.map((c: PublicComment) => ( + + )) + )} +
+
+
+ ); +} diff --git a/components/posts/comment-form.tsx b/components/posts/comment-form.tsx new file mode 100644 index 0000000..a225600 --- /dev/null +++ b/components/posts/comment-form.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { createComment } from "@/server/actions/comments"; + +type Props = { postId: string; authenticated: boolean; boardSlug: string }; + +export function CommentForm({ postId, authenticated, boardSlug }: Props) { + const router = useRouter(); + const [content, setContent] = useState(""); + const [isPending, startTransition] = useTransition(); + const textareaRef = useRef(null); + + if (!authenticated) { + return ( +
+ {" "} + to leave a comment. +
+ ); + } + + function handleSubmit() { + if (!content.trim()) return; + startTransition(async () => { + const result = await createComment({ postId, content }); + if (!result.ok) { + toast.error(result.error); + return; + } + setContent(""); + textareaRef.current?.focus(); + }); + } + + return ( +
+