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