fix: render full comment thread depth instead of truncating at 2 levels

DB/API already supported arbitrary-depth replies via the self-referencing
parentId, but the UI only rendered top-level + one reply level — a
reply-to-a-reply was fetched but never shown. Rewrite CommentItem as a
recursive component keyed off a parentId->children map; visual indent
caps at 4 levels (Reddit-style flatten) but nesting itself is unbounded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-03 21:58:53 +02:00
parent 1abab17ca8
commit a3f387fa2a
+38 -33
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { MessageCircle, Reply, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { CommentReactions } from "@/components/social/comment-reactions";
import { cn } from "@/lib/utils";
type Comment = {
id: string;
@@ -21,6 +22,8 @@ type Comment = {
userAvatarUrl: string | null;
};
const MAX_VISUAL_INDENT = 4;
function timeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
@@ -85,13 +88,15 @@ function CommentForm({
function CommentItem({
comment,
replies,
childrenByParent,
depth,
currentUserId,
recipeId,
onRefresh,
}: {
comment: Comment;
replies: Comment[];
childrenByParent: Map<string, Comment[]>;
depth: number;
currentUserId?: string;
recipeId: string;
onRefresh: () => void;
@@ -99,6 +104,8 @@ function CommentItem({
const [showReply, setShowReply] = useState(false);
const isOwn = comment.userId === currentUserId;
const t = useTranslations("social");
const replies = childrenByParent.get(comment.id) ?? [];
const indented = depth > 0 && depth <= MAX_VISUAL_INDENT;
async function deleteComment() {
const res = await fetch(`/api/v1/comments/${comment.id}`, { method: "DELETE" });
@@ -107,9 +114,9 @@ function CommentItem({
}
return (
<div className="space-y-3">
<div className={cn("space-y-3", indented && "ml-10 border-l pl-4")}>
<div className="flex gap-3">
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
<Avatar className={cn("shrink-0 mt-0.5", depth === 0 ? "h-7 w-7" : "h-6 w-6")}>
<AvatarImage src={comment.userAvatarUrl ?? ""} />
<AvatarFallback className="text-xs">{comment.userName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
@@ -151,32 +158,17 @@ function CommentItem({
</div>
{replies.length > 0 && (
<div className="ml-10 space-y-3 border-l pl-4">
<div className="space-y-3">
{replies.map((reply) => (
<div key={reply.id} className="flex gap-3">
<Avatar className="h-6 w-6 shrink-0 mt-0.5">
<AvatarImage src={reply.userAvatarUrl ?? ""} />
<AvatarFallback className="text-xs">{reply.userName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{reply.userName}</span>
<span className="text-xs text-muted-foreground">{timeAgo(reply.createdAt)}</span>
</div>
<p className="text-sm leading-relaxed">{reply.content}</p>
{reply.userId === currentUserId && (
<button
onClick={async () => {
const res = await fetch(`/api/v1/comments/${reply.id}`, { method: "DELETE" });
if (res.ok) { toast.success("Deleted"); onRefresh(); }
}}
className="text-xs text-muted-foreground hover:text-destructive flex items-center gap-1"
>
<Trash2 className="h-3 w-3" /> Delete
</button>
)}
</div>
</div>
<CommentItem
key={reply.id}
comment={reply}
childrenByParent={childrenByParent}
depth={depth + 1}
currentUserId={currentUserId}
recipeId={recipeId}
onRefresh={onRefresh}
/>
))}
</div>
)}
@@ -203,8 +195,20 @@ export function CommentsSection({
useEffect(() => { void load(); }, [load]);
const topLevel = comments.filter((c) => !c.parentId);
const replies = (parentId: string) => comments.filter((c) => c.parentId === parentId);
const { topLevel, childrenByParent } = useMemo(() => {
const byParent = new Map<string, Comment[]>();
const top: Comment[] = [];
for (const c of comments) {
if (!c.parentId) {
top.push(c);
continue;
}
const siblings = byParent.get(c.parentId) ?? [];
siblings.push(c);
byParent.set(c.parentId, siblings);
}
return { topLevel: top, childrenByParent: byParent };
}, [comments]);
return (
<div className="space-y-6">
@@ -229,7 +233,8 @@ export function CommentsSection({
{i > 0 && <Separator className="mb-6" />}
<CommentItem
comment={comment}
replies={replies(comment.id)}
childrenByParent={childrenByParent}
depth={0}
currentUserId={currentUserId}
recipeId={recipeId}
onRefresh={load}