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"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { MessageCircle, Reply, Trash2 } from "lucide-react"; import { MessageCircle, Reply, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CommentReactions } from "@/components/social/comment-reactions"; import { CommentReactions } from "@/components/social/comment-reactions";
import { cn } from "@/lib/utils";
type Comment = { type Comment = {
id: string; id: string;
@@ -21,6 +22,8 @@ type Comment = {
userAvatarUrl: string | null; userAvatarUrl: string | null;
}; };
const MAX_VISUAL_INDENT = 4;
function timeAgo(dateStr: string) { function timeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
@@ -85,13 +88,15 @@ function CommentForm({
function CommentItem({ function CommentItem({
comment, comment,
replies, childrenByParent,
depth,
currentUserId, currentUserId,
recipeId, recipeId,
onRefresh, onRefresh,
}: { }: {
comment: Comment; comment: Comment;
replies: Comment[]; childrenByParent: Map<string, Comment[]>;
depth: number;
currentUserId?: string; currentUserId?: string;
recipeId: string; recipeId: string;
onRefresh: () => void; onRefresh: () => void;
@@ -99,6 +104,8 @@ function CommentItem({
const [showReply, setShowReply] = useState(false); const [showReply, setShowReply] = useState(false);
const isOwn = comment.userId === currentUserId; const isOwn = comment.userId === currentUserId;
const t = useTranslations("social"); const t = useTranslations("social");
const replies = childrenByParent.get(comment.id) ?? [];
const indented = depth > 0 && depth <= MAX_VISUAL_INDENT;
async function deleteComment() { async function deleteComment() {
const res = await fetch(`/api/v1/comments/${comment.id}`, { method: "DELETE" }); const res = await fetch(`/api/v1/comments/${comment.id}`, { method: "DELETE" });
@@ -107,9 +114,9 @@ function CommentItem({
} }
return ( return (
<div className="space-y-3"> <div className={cn("space-y-3", indented && "ml-10 border-l pl-4")}>
<div className="flex gap-3"> <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 ?? ""} /> <AvatarImage src={comment.userAvatarUrl ?? ""} />
<AvatarFallback className="text-xs">{comment.userName.slice(0, 2).toUpperCase()}</AvatarFallback> <AvatarFallback className="text-xs">{comment.userName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
@@ -151,32 +158,17 @@ function CommentItem({
</div> </div>
{replies.length > 0 && ( {replies.length > 0 && (
<div className="ml-10 space-y-3 border-l pl-4"> <div className="space-y-3">
{replies.map((reply) => ( {replies.map((reply) => (
<div key={reply.id} className="flex gap-3"> <CommentItem
<Avatar className="h-6 w-6 shrink-0 mt-0.5"> key={reply.id}
<AvatarImage src={reply.userAvatarUrl ?? ""} /> comment={reply}
<AvatarFallback className="text-xs">{reply.userName.slice(0, 2).toUpperCase()}</AvatarFallback> childrenByParent={childrenByParent}
</Avatar> depth={depth + 1}
<div className="flex-1 min-w-0 space-y-1"> currentUserId={currentUserId}
<div className="flex items-baseline gap-2"> recipeId={recipeId}
<span className="font-medium text-sm">{reply.userName}</span> onRefresh={onRefresh}
<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>
))} ))}
</div> </div>
)} )}
@@ -203,8 +195,20 @@ export function CommentsSection({
useEffect(() => { void load(); }, [load]); useEffect(() => { void load(); }, [load]);
const topLevel = comments.filter((c) => !c.parentId); const { topLevel, childrenByParent } = useMemo(() => {
const replies = (parentId: string) => comments.filter((c) => c.parentId === parentId); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -229,7 +233,8 @@ export function CommentsSection({
{i > 0 && <Separator className="mb-6" />} {i > 0 && <Separator className="mb-6" />}
<CommentItem <CommentItem
comment={comment} comment={comment}
replies={replies(comment.id)} childrenByParent={childrenByParent}
depth={0}
currentUserId={currentUserId} currentUserId={currentUserId}
recipeId={recipeId} recipeId={recipeId}
onRefresh={load} onRefresh={load}