From e567093b99a863a0352c7c97270084b332888dd1 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:59:54 +0200 Subject: [PATCH] feat(card): comment thread with role-appropriate write path --- src/components/card/comment-thread.tsx | 124 +++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/components/card/comment-thread.tsx diff --git a/src/components/card/comment-thread.tsx b/src/components/card/comment-thread.tsx new file mode 100644 index 0000000..e28d98b --- /dev/null +++ b/src/components/card/comment-thread.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { addOwnerComment } from "@/app/actions/comments"; +import { relativeTime } from "@/lib/time/relative"; +import type { CommentRecord, Role } from "@/lib/pb/types"; + +export function CommentThread({ + cardId, + comments, + names, + role, + clientSlug, + projectSlug, +}: { + cardId: string; + comments: CommentRecord[]; + names: Record; + role: Role; + clientSlug: string; + projectSlug: string; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + const body = draft.trim(); + if (!body) return; + + setError(null); + + if (role === "owner") { + setDraft(""); + startTransition(() => + addOwnerComment({ cardId, body, clientSlug, projectSlug }).then( + () => undefined + ) + ); + return; + } + + // Client managers post through the hardened route, not the action. + const response = await fetch(`/api/client/cards/${cardId}/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body }), + }); + + if (!response.ok) { + setError("Could not post that comment"); + return; + } + + setDraft(""); + router.refresh(); + } + + return ( +
+

+ Comments +

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

No comments yet.

+ )} + +
    + {comments.map((comment) => ( +
  • +
    + + {names[comment.author] ?? "Someone"} + + + {relativeTime(comment.created)} + +
    + {/* + Rendered as plain text on purpose. The body is markdown authored + by a client and is never passed to dangerouslySetInnerHTML. + whitespace-pre-wrap preserves their line breaks without + interpreting anything. + */} +

    + {comment.body} +

    +
  • + ))} +
+ +
+