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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-28 22:39:02 +08:00
parent 56c06ec13b
commit 0c8738676c
2 changed files with 309 additions and 0 deletions

View file

@ -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<string, TimelineEntry[]>;
currentUserId?: string;
onReply: (parentId: string, content: string) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
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<void>;
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 (
<div className={`group/comment py-3${isTemp ? " opacity-60" : ""}`}>
<div className="flex items-center gap-2.5">
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
<span className="text-sm font-medium">
{getActorName(entry.actor_type, entry.actor_id)}
</span>
<Tooltip>
<TooltipTrigger
render={
<span className="text-xs text-muted-foreground cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
{!isTemp && (isOwn) && (
<div className="ml-auto opacity-0 group-hover/comment:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 data-[popup-open]:opacity-100 data-[popup-open]:bg-accent/50 transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={startEdit}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onDelete(entry.id)} variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{editing ? (
<form
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
className="mt-2 pl-8"
>
<input
autoFocus
value={editContent}
onChange={(e) => 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(); }}
/>
<div className="flex gap-2 mt-1.5">
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
</div>
</form>
) : (
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<Card className={`overflow-hidden${entry.id.startsWith("temp-") ? " opacity-60" : ""}`}>
{/* Parent comment */}
<div className="px-4">
<CommentRow
entry={entry}
currentUserId={currentUserId}
onEdit={onEdit}
onDelete={onDelete}
/>
</div>
{/* Replies — flat, separated by border */}
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t px-4">
<CommentRow
entry={reply}
currentUserId={currentUserId}
onEdit={onEdit}
onDelete={onDelete}
/>
</div>
))}
{/* Reply input — always visible at bottom */}
<div className="border-t px-4 py-2">
<ReplyInput
placeholder="Leave a reply..."
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
onSubmit={(content) => onReply(entry.id, content)}
/>
</div>
</Card>
);
}
export { CommentCard, type CommentCardProps };

View file

@ -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<void>;
size?: "sm" | "default";
}
// ---------------------------------------------------------------------------
// ReplyInput
// ---------------------------------------------------------------------------
function ReplyInput({
placeholder = "Leave a reply...",
avatarType,
avatarId,
onSubmit,
size = "default",
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(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 (
<div className="flex items-center gap-2.5">
<ActorAvatar
actorType={avatarType}
actorId={avatarId}
size={avatarSize}
className="shrink-0"
/>
<div
className={`min-w-0 flex-1 overflow-y-auto ${
size === "sm" ? "max-h-32" : "max-h-48"
}`}
>
<RichTextEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
debounceMs={100}
/>
</div>
<Button
size="icon-xs"
variant="ghost"
disabled={isEmpty || submitting}
onClick={handleSubmit}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
</div>
);
}
export { ReplyInput, type ReplyInputProps };