feat(card): comment thread with role-appropriate write path

This commit is contained in:
Dave 2026-09-16 10:59:54 +02:00
parent dd9cbe6c24
commit e567093b99

View file

@ -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<string, string>;
role: Role;
clientSlug: string;
projectSlug: string;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(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 (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Comments
</h2>
{comments.length === 0 && (
<p className="text-sm text-text-muted">No comments yet.</p>
)}
<ul className="space-y-3">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3"
>
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-text-primary">
{names[comment.author] ?? "Someone"}
</span>
<span className="text-xs text-text-muted">
{relativeTime(comment.created)}
</span>
</div>
{/*
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.
*/}
<p className="mt-1.5 whitespace-pre-wrap text-sm text-text-secondary">
{comment.body}
</p>
</li>
))}
</ul>
<form onSubmit={submit} className="space-y-2">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={3}
placeholder="Write a comment"
disabled={pending}
className="w-full rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-3 text-sm text-text-primary outline-none placeholder:text-text-muted focus:border-brand"
/>
{error && (
<p role="alert" className="text-xs text-accent">
{error}
</p>
)}
<button
type="submit"
disabled={pending || !draft.trim()}
className="rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover disabled:opacity-50"
>
{pending ? "Posting…" : "Comment"}
</button>
</form>
</section>
);
}