From 0c8738676cf20bf009662b1467a22bd5f3d2bbd4 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:39:02 +0800 Subject: [PATCH] refactor(comments): flat thread layout in one Card (Linear-style) Replace recursive Card-in-Card with flat thread layout: - One Card per comment thread, parent + all replies flat inside - Replies separated by border-t, not nested Cards - CommentRow component handles each individual comment (header + content + edit) - Three-dot menu shows active state when open (data-[popup-open]) - ReplyInput simplified: avatar + editor + submit button, no extra border container - Nested replies collected recursively but rendered flat Co-Authored-By: Claude Opus 4.6 (1M context) --- .../issues/components/comment-card.tsx | 224 ++++++++++++++++++ .../issues/components/reply-input.tsx | 85 +++++++ 2 files changed, 309 insertions(+) create mode 100644 apps/web/features/issues/components/comment-card.tsx create mode 100644 apps/web/features/issues/components/reply-input.tsx diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx new file mode 100644 index 00000000..4ad2f0d8 --- /dev/null +++ b/apps/web/features/issues/components/comment-card.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontal } from "lucide-react"; +import { toast } from "sonner"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { Markdown } from "@/components/markdown"; +import { useActorName } from "@/features/workspace"; +import { ReplyInput } from "./reply-input"; +import type { TimelineEntry } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CommentCardProps { + entry: TimelineEntry; + replies: TimelineEntry[]; + allReplies: Map; + currentUserId?: string; + onReply: (parentId: string, content: string) => Promise; + onEdit: (commentId: string, content: string) => Promise; + onDelete: (commentId: string) => void; +} + +// --------------------------------------------------------------------------- +// Single comment row (used for both parent and replies within the same Card) +// --------------------------------------------------------------------------- + +function CommentRow({ + entry, + currentUserId, + onEdit, + onDelete, +}: { + entry: TimelineEntry; + currentUserId?: string; + onEdit: (commentId: string, content: string) => Promise; + onDelete: (commentId: string) => void; +}) { + const { getActorName } = useActorName(); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(""); + + const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; + const isTemp = entry.id.startsWith("temp-"); + + const startEdit = () => { + setEditContent(entry.content ?? ""); + setEditing(true); + }; + + const cancelEdit = () => { + setEditing(false); + setEditContent(""); + }; + + const saveEdit = async () => { + const trimmed = editContent.trim(); + if (!trimmed) return; + try { + await onEdit(entry.id, trimmed); + setEditing(false); + setEditContent(""); + } catch { + toast.error("Failed to update comment"); + } + }; + + return ( +
+
+ + + {getActorName(entry.actor_type, entry.actor_id)} + + + + {timeAgo(entry.created_at)} + + } + /> + + {new Date(entry.created_at).toLocaleString()} + + + + {!isTemp && (isOwn) && ( +
+ + + + + + Edit + + onDelete(entry.id)} variant="destructive"> + Delete + + + +
+ )} +
+ + {editing ? ( +
{ e.preventDefault(); saveEdit(); }} + className="mt-2 pl-8" + > + setEditContent(e.target.value)} + aria-label="Edit comment" + className="w-full text-sm bg-transparent border-b border-border outline-none py-1" + onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} + /> +
+ + +
+
+ ) : ( +
+ {entry.content ?? ""} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// CommentCard — One Card per thread (parent + all replies flat inside) +// --------------------------------------------------------------------------- + +function CommentCard({ + entry, + replies, + allReplies, + currentUserId, + onReply, + onEdit, + onDelete, +}: CommentCardProps) { + // Collect all nested replies recursively into a flat list + const allNestedReplies: TimelineEntry[] = []; + const collectReplies = (parentId: string) => { + const children = allReplies.get(parentId) ?? []; + for (const child of children) { + allNestedReplies.push(child); + collectReplies(child.id); + } + }; + collectReplies(entry.id); + + return ( + + {/* Parent comment */} +
+ +
+ + {/* Replies — flat, separated by border */} + {allNestedReplies.map((reply) => ( +
+ +
+ ))} + + {/* Reply input — always visible at bottom */} +
+ onReply(entry.id, content)} + /> +
+
+ ); +} + +export { CommentCard, type CommentCardProps }; diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx new file mode 100644 index 00000000..4ba85787 --- /dev/null +++ b/apps/web/features/issues/components/reply-input.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRef, useState } from "react"; +import { ArrowUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; +import { ActorAvatar } from "@/components/common/actor-avatar"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ReplyInputProps { + placeholder?: string; + avatarType: string; + avatarId: string; + onSubmit: (content: string) => Promise; + size?: "sm" | "default"; +} + +// --------------------------------------------------------------------------- +// ReplyInput +// --------------------------------------------------------------------------- + +function ReplyInput({ + placeholder = "Leave a reply...", + avatarType, + avatarId, + onSubmit, + size = "default", +}: ReplyInputProps) { + const editorRef = useRef(null); + const [isEmpty, setIsEmpty] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + const content = editorRef.current?.getMarkdown()?.trim(); + if (!content || submitting) return; + setSubmitting(true); + try { + await onSubmit(content); + editorRef.current?.clearContent(); + setIsEmpty(true); + } finally { + setSubmitting(false); + } + }; + + const avatarSize = size === "sm" ? 22 : 28; + + return ( +
+ +
+ setIsEmpty(!md.trim())} + onSubmit={handleSubmit} + debounceMs={100} + /> +
+ +
+ ); +} + +export { ReplyInput, type ReplyInputProps };