fix(blog): flatten nested comment replies
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Edit3,
|
Edit3,
|
||||||
@@ -38,6 +38,11 @@ type Props = {
|
|||||||
initialComments: number;
|
initialComments: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FlattenedReply = {
|
||||||
|
comment: Types.CommentSchema;
|
||||||
|
replyTo: Types.CommentSchema;
|
||||||
|
};
|
||||||
|
|
||||||
function displayName(author: Types.CommentSchema["author"]) {
|
function displayName(author: Types.CommentSchema["author"]) {
|
||||||
return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username;
|
return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username;
|
||||||
}
|
}
|
||||||
@@ -57,6 +62,20 @@ function allDescendants(comment: Types.CommentSchema): Types.CommentSchema[] {
|
|||||||
return (comment.replies || []).flatMap((reply) => [reply, ...allDescendants(reply)]);
|
return (comment.replies || []).flatMap((reply) => [reply, ...allDescendants(reply)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flattenReplies(comment: Types.CommentSchema): FlattenedReply[] {
|
||||||
|
const replies: FlattenedReply[] = [];
|
||||||
|
|
||||||
|
const walk = (items: Types.CommentSchema[] | undefined, parent: Types.CommentSchema) => {
|
||||||
|
(items || []).forEach((reply) => {
|
||||||
|
replies.push({ comment: reply, replyTo: parent });
|
||||||
|
walk(reply.replies, reply);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(comment.replies, comment);
|
||||||
|
return replies;
|
||||||
|
}
|
||||||
|
|
||||||
function findComment(comments: Types.CommentSchema[], id: number | null): Types.CommentSchema | undefined {
|
function findComment(comments: Types.CommentSchema[], id: number | null): Types.CommentSchema | undefined {
|
||||||
if (!id) return undefined;
|
if (!id) return undefined;
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
@@ -79,12 +98,13 @@ export default function BlogPostInteractions({
|
|||||||
const [replyTo, setReplyTo] = useState<number | null>(null);
|
const [replyTo, setReplyTo] = useState<number | null>(null);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editContent, setEditContent] = useState("");
|
const [editContent, setEditContent] = useState("");
|
||||||
const [expandedReplies, setExpandedReplies] = useState<Set<number>>(new Set());
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Types.CommentSchema | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Types.CommentSchema | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [moderatingId, setModeratingId] = useState<number | null>(null);
|
const [moderatingId, setModeratingId] = useState<number | null>(null);
|
||||||
|
const [highlightedCommentId, setHighlightedCommentId] = useState<number | null>(null);
|
||||||
|
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||||
|
|
||||||
@@ -115,6 +135,28 @@ export default function BlogPostInteractions({
|
|||||||
};
|
};
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
clearTimeout(highlightTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToComment = (commentId: number) => {
|
||||||
|
const element = document.getElementById(`blog-comment-${commentId}`);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
clearTimeout(highlightTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
setHighlightedCommentId(commentId);
|
||||||
|
highlightTimeoutRef.current = setTimeout(() => {
|
||||||
|
setHighlightedCommentId(null);
|
||||||
|
highlightTimeoutRef.current = null;
|
||||||
|
}, 1800);
|
||||||
|
};
|
||||||
|
|
||||||
const submitComment = async () => {
|
const submitComment = async () => {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -220,32 +262,32 @@ export default function BlogPostInteractions({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpandedReplies = (commentId: number) => {
|
const renderComment = (
|
||||||
setExpandedReplies((current) => {
|
comment: Types.CommentSchema,
|
||||||
const next = new Set(current);
|
options: {
|
||||||
if (next.has(commentId)) {
|
parentHidden?: boolean;
|
||||||
next.delete(commentId);
|
replyToComment?: Types.CommentSchema;
|
||||||
} else {
|
topLevelParent?: Types.CommentSchema;
|
||||||
next.add(commentId);
|
} = {},
|
||||||
}
|
) => {
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderComment = (comment: Types.CommentSchema, nested = false, compact = false, parentHidden = false) => {
|
|
||||||
const isOwnComment = Boolean(user?.id === comment.author.id);
|
const isOwnComment = Boolean(user?.id === comment.author.id);
|
||||||
const isEditing = editingId === comment.id;
|
const isEditing = editingId === comment.id;
|
||||||
|
const { parentHidden = false, replyToComment, topLevelParent } = options;
|
||||||
const hidden = parentHidden || Boolean(comment.is_hidden);
|
const hidden = parentHidden || Boolean(comment.is_hidden);
|
||||||
const deeperReplies = nested ? allDescendants(comment) : [];
|
const isReply = Boolean(replyToComment);
|
||||||
const showDeeperReplies = expandedReplies.has(comment.id);
|
const flattenedReplies = isReply ? [] : flattenReplies(comment);
|
||||||
|
const showReplyContext = Boolean(
|
||||||
|
replyToComment && topLevelParent && replyToComment.id !== topLevelParent.id,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`blog-comment-${comment.id}`}
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative",
|
"relative scroll-mt-28 rounded-3xl border p-4 shadow-sm transition-colors duration-300 border-border/70 bg-muted/20",
|
||||||
nested && "mr-6 border-r border-primary/20 pr-4",
|
hidden && "border-amber-400/40 bg-amber-50/50 dark:bg-amber-950/20",
|
||||||
compact && "rounded-2xl bg-muted/25 p-3",
|
highlightedCommentId === comment.id && "border-primary bg-primary/10 shadow-lg shadow-primary/20 ring-2 ring-primary/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn("flex items-start gap-3 text-right", hidden && "opacity-75")}>
|
<div className={cn("flex items-start gap-3 text-right", hidden && "opacity-75")}>
|
||||||
@@ -258,6 +300,15 @@ export default function BlogPostInteractions({
|
|||||||
<time className="text-xs text-muted-foreground" dateTime={comment.created_at}>
|
<time className="text-xs text-muted-foreground" dateTime={comment.created_at}>
|
||||||
{formatJalaliDate(comment.created_at)}
|
{formatJalaliDate(comment.created_at)}
|
||||||
</time>
|
</time>
|
||||||
|
{showReplyContext && replyToComment ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary transition hover:bg-primary/20 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
onClick={() => scrollToComment(replyToComment.id)}
|
||||||
|
>
|
||||||
|
در پاسخ به {displayName(replyToComment.author)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{hidden ? (
|
{hidden ? (
|
||||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-300">
|
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-300">
|
||||||
مخفی شده
|
مخفی شده
|
||||||
@@ -289,7 +340,7 @@ export default function BlogPostInteractions({
|
|||||||
</div>
|
</div>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-start gap-2 text-xs text-muted-foreground">
|
<div className="mt-2 flex flex-wrap items-center justify-start gap-2 text-xs text-muted-foreground">
|
||||||
{isAuthenticated && !hidden && !compact ? (
|
{isAuthenticated && !hidden ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -351,27 +402,15 @@ export default function BlogPostInteractions({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!compact && !nested && comment.replies?.length ? (
|
{flattenedReplies.length ? (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-3 border-r border-primary/15 pr-4">
|
||||||
{comment.replies.map((reply) => renderComment(reply, true, false, hidden))}
|
{flattenedReplies.map(({ comment: reply, replyTo }) => (
|
||||||
</div>
|
renderComment(reply, {
|
||||||
) : null}
|
parentHidden: hidden,
|
||||||
{!compact && nested && deeperReplies.length ? (
|
replyToComment: replyTo,
|
||||||
<div className="mt-3 mr-6">
|
topLevelParent: comment,
|
||||||
<Button
|
})
|
||||||
type="button"
|
))}
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full text-xs"
|
|
||||||
onClick={() => toggleExpandedReplies(comment.id)}
|
|
||||||
>
|
|
||||||
{showDeeperReplies ? "بستن پاسخهای بیشتر" : `نمایش ${toPersianDigits(deeperReplies.length)} پاسخ بیشتر`}
|
|
||||||
</Button>
|
|
||||||
{showDeeperReplies ? (
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
{deeperReplies.map((reply) => renderComment(reply, false, true, hidden))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user