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:
parent
56c06ec13b
commit
0c8738676c
2 changed files with 309 additions and 0 deletions
224
apps/web/features/issues/components/comment-card.tsx
Normal file
224
apps/web/features/issues/components/comment-card.tsx
Normal 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 };
|
||||
85
apps/web/features/issues/components/reply-input.tsx
Normal file
85
apps/web/features/issues/components/reply-input.tsx
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue