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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user