Add update method to ReviewCommentRepository, updateReviewComment tRPC procedure, and inline edit UI in CommentThread. Edit button appears on user-authored comments (not agent comments) when unresolved. Uses the existing CommentForm with a new initialValue prop.
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { Check, RotateCcw, Reply, Pencil } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CommentForm } from "./CommentForm";
|
|
import type { ReviewComment } from "./types";
|
|
|
|
interface CommentThreadProps {
|
|
comments: ReviewComment[];
|
|
onResolve: (commentId: string) => void;
|
|
onUnresolve: (commentId: string) => void;
|
|
onReply?: (parentCommentId: string, body: string) => void;
|
|
onEdit?: (commentId: string, body: string) => void;
|
|
}
|
|
|
|
export function CommentThread({ comments, onResolve, onUnresolve, onReply, onEdit }: CommentThreadProps) {
|
|
// Group: root comments (no parentCommentId) and their replies
|
|
const rootComments = comments.filter((c) => !c.parentCommentId);
|
|
const repliesByParent = new Map<string, ReviewComment[]>();
|
|
for (const c of comments) {
|
|
if (c.parentCommentId) {
|
|
const arr = repliesByParent.get(c.parentCommentId) ?? [];
|
|
arr.push(c);
|
|
repliesByParent.set(c.parentCommentId, arr);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{rootComments.map((comment) => (
|
|
<RootComment
|
|
key={comment.id}
|
|
comment={comment}
|
|
replies={repliesByParent.get(comment.id) ?? []}
|
|
onResolve={onResolve}
|
|
onUnresolve={onUnresolve}
|
|
onReply={onReply}
|
|
onEdit={onEdit}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RootComment({
|
|
comment,
|
|
replies,
|
|
onResolve,
|
|
onUnresolve,
|
|
onReply,
|
|
onEdit,
|
|
}: {
|
|
comment: ReviewComment;
|
|
replies: ReviewComment[];
|
|
onResolve: (id: string) => void;
|
|
onUnresolve: (id: string) => void;
|
|
onReply?: (parentCommentId: string, body: string) => void;
|
|
onEdit?: (commentId: string, body: string) => void;
|
|
}) {
|
|
const [isReplying, setIsReplying] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const replyRef = useRef<HTMLTextAreaElement>(null);
|
|
const editRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (isReplying) replyRef.current?.focus();
|
|
}, [isReplying]);
|
|
|
|
useEffect(() => {
|
|
if (editingId) editRef.current?.focus();
|
|
}, [editingId]);
|
|
|
|
const isEditingRoot = editingId === comment.id;
|
|
|
|
return (
|
|
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
|
|
{/* Root comment */}
|
|
<div className="p-2.5 text-xs space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-semibold text-foreground">{comment.author}</span>
|
|
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
|
|
{comment.resolved && (
|
|
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
|
<Check className="h-3 w-3" />
|
|
Resolved
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
{onEdit && comment.author !== "agent" && !comment.resolved && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-1.5 text-[10px]"
|
|
onClick={() => setEditingId(isEditingRoot ? null : comment.id)}
|
|
>
|
|
<Pencil className="h-3 w-3 mr-0.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
{onReply && !comment.resolved && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-1.5 text-[10px]"
|
|
onClick={() => setIsReplying(!isReplying)}
|
|
>
|
|
<Reply className="h-3 w-3 mr-0.5" />
|
|
Reply
|
|
</Button>
|
|
)}
|
|
{comment.resolved ? (
|
|
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
|
|
<RotateCcw className="h-3 w-3 mr-0.5" />
|
|
Reopen
|
|
</Button>
|
|
) : (
|
|
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
|
|
<Check className="h-3 w-3 mr-0.5" />
|
|
Resolve
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isEditingRoot ? (
|
|
<CommentForm
|
|
ref={editRef}
|
|
initialValue={comment.body}
|
|
onSubmit={(body) => {
|
|
onEdit!(comment.id, body);
|
|
setEditingId(null);
|
|
}}
|
|
onCancel={() => setEditingId(null)}
|
|
placeholder="Edit comment..."
|
|
submitLabel="Save"
|
|
/>
|
|
) : (
|
|
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Replies */}
|
|
{replies.length > 0 && (
|
|
<div className="border-t border-border/50">
|
|
{replies.map((reply) => (
|
|
<div
|
|
key={reply.id}
|
|
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
|
|
reply.author === "agent"
|
|
? "border-l-primary bg-primary/5"
|
|
: "border-l-muted-foreground/30"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
|
|
{reply.author}
|
|
</span>
|
|
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
|
|
</div>
|
|
{onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1 text-[10px]"
|
|
onClick={() => setEditingId(reply.id)}
|
|
>
|
|
<Pencil className="h-2.5 w-2.5 mr-0.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{editingId === reply.id ? (
|
|
<CommentForm
|
|
ref={editRef}
|
|
initialValue={reply.body}
|
|
onSubmit={(body) => {
|
|
onEdit!(reply.id, body);
|
|
setEditingId(null);
|
|
}}
|
|
onCancel={() => setEditingId(null)}
|
|
placeholder="Edit reply..."
|
|
submitLabel="Save"
|
|
/>
|
|
) : (
|
|
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Reply form */}
|
|
{isReplying && onReply && (
|
|
<div className="border-t border-border/50 p-2.5">
|
|
<CommentForm
|
|
ref={replyRef}
|
|
onSubmit={(body) => {
|
|
onReply(comment.id, body);
|
|
setIsReplying(false);
|
|
}}
|
|
onCancel={() => setIsReplying(false)}
|
|
placeholder="Write a reply..."
|
|
submitLabel="Reply"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
}
|