feat(card): comment thread with role-appropriate write path
This commit is contained in:
parent
dd9cbe6c24
commit
e567093b99
1 changed files with 124 additions and 0 deletions
124
src/components/card/comment-thread.tsx
Normal file
124
src/components/card/comment-thread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue