Compare commits

...

3 Commits

Author SHA1 Message Date
bced5dceb1 fix(blog): collapse inactive toc branches
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-12 11:21:19 +03:30
e06a4b1cf8 fix(blog): flatten nested comment replies 2026-06-12 11:21:11 +03:30
b953d78b19 fix(profile): wire blog activity data 2026-06-12 11:21:04 +03:30
3 changed files with 171 additions and 64 deletions

View File

@@ -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>

View File

@@ -8,9 +8,37 @@ type Props = {
headings: MarkdownHeading[]; headings: MarkdownHeading[];
}; };
function getParentHeading(headings: MarkdownHeading[], index: number) {
const heading = headings[index];
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
if (headings[cursor].level < heading.level) {
return headings[cursor];
}
}
return null;
}
export default function BlogTableOfContents({ headings }: Props) { export default function BlogTableOfContents({ headings }: Props) {
const [activeId, setActiveId] = useState(headings[0]?.id ?? ""); const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
const parentById = new Map<string, string | null>();
headings.forEach((heading, index) => {
parentById.set(heading.id, getParentHeading(headings, index)?.id ?? null);
});
const activeBranch = new Set<string>();
let cursor = activeId;
while (cursor) {
activeBranch.add(cursor);
cursor = parentById.get(cursor) ?? "";
}
const visibleHeadings = headings.filter((heading) => {
const parentId = parentById.get(heading.id);
if (!parentId) return true;
return activeBranch.has(parentId);
});
useEffect(() => { useEffect(() => {
if (!headings.length) return; if (!headings.length) return;
@@ -51,7 +79,7 @@ export default function BlogTableOfContents({ headings }: Props) {
return ( return (
<nav className="space-y-1 text-sm"> <nav className="space-y-1 text-sm">
{headings.map((heading) => { {visibleHeadings.map((heading) => {
const active = activeId === heading.id; const active = activeId === heading.id;
return ( return (
<button <button

View File

@@ -13,21 +13,23 @@ import {
Loader2, Loader2,
MessageSquareText, MessageSquareText,
PencilLine, PencilLine,
Reply,
Trash2, Trash2,
UserRound, UserRound,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import BlogThumbnail from "@/components/BlogThumbnail";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import { Helmet } from "@/lib/helmet"; import { Helmet } from "@/lib/helmet";
import { Link, Navigate } from "@/lib/router"; import { Link, Navigate } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { blogPostPath } from "@/lib/blog-routes"; import { blogPostPath } from "@/lib/blog-routes";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
import type * as Types from "@/lib/types"; import type * as Types from "@/lib/types";
import { import {
formatJalali, formatJalali,
formatNumberPersian, formatNumberPersian,
getBlogCardImageUrl,
resolveErrorMessage, resolveErrorMessage,
toPersianDigits, toPersianDigits,
} from "@/lib/utils"; } from "@/lib/utils";
@@ -41,7 +43,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
type EventTab = "confirmed" | "pending" | "cancelled"; type EventTab = "confirmed" | "pending" | "cancelled";
type BlogTab = "liked" | "saved" | "comments" | "replies"; type BlogTab = "liked" | "saved" | "comments";
function InfoRow({ label, value }: { label: string; value: ReactNode }) { function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return ( return (
@@ -144,7 +146,12 @@ export default function Profile() {
staleTime: 7 * 24 * 60 * 60 * 1000, staleTime: 7 * 24 * 60 * 60 * 1000,
}); });
const { data: blogActivity } = useQuery({ const {
data: blogActivity,
isLoading: blogActivityLoading,
isError: blogActivityError,
refetch: refetchBlogActivity,
} = useQuery({
queryKey: ["my-blog-activity"], queryKey: ["my-blog-activity"],
queryFn: () => api.getMyBlogActivity(), queryFn: () => api.getMyBlogActivity(),
enabled: isAuthenticated, enabled: isAuthenticated,
@@ -261,12 +268,20 @@ export default function Profile() {
? pendingRegistrations ? pendingRegistrations
: canceledRegistrations; : canceledRegistrations;
const blogCommentActivity = useMemo(
() =>
[...(blogActivity?.comments ?? []), ...(blogActivity?.replies ?? [])].sort(
(left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime(),
),
[blogActivity?.comments, blogActivity?.replies],
);
const blogCounts = { const blogCounts = {
liked: blogActivity?.liked_posts.length ?? 0, liked: blogActivity?.liked_posts.length ?? 0,
saved: blogActivity?.saved_posts.length ?? 0, saved: blogActivity?.saved_posts.length ?? 0,
comments: blogActivity?.comments.length ?? 0, comments: blogCommentActivity.length,
replies: blogActivity?.replies.length ?? 0,
}; };
const showBlogActivityTabs = !blogActivityLoading && !blogActivityError;
const pageTitle = `پروفایل ${fullName} | انجمن علمی مهندسی کامپیوتر`; const pageTitle = `پروفایل ${fullName} | انجمن علمی مهندسی کامپیوتر`;
const pageDescription = `داشبورد حساب ${fullName} در انجمن علمی مهندسی کامپیوتر.`; const pageDescription = `داشبورد حساب ${fullName} در انجمن علمی مهندسی کامپیوتر.`;
@@ -410,11 +425,22 @@ export default function Profile() {
const renderPostRow = (post: Types.PostListSchema) => ( const renderPostRow = (post: Types.PostListSchema) => (
<div key={post.id} className="rounded-2xl border border-border/70 bg-background/80 p-4 text-right"> <div key={post.id} className="rounded-2xl border border-border/70 bg-background/80 p-4 text-right">
<div className="flex flex-row gap-4">
<Link to={blogPostPath(post.slug)} className="block w-24 shrink-0 overflow-hidden rounded-2xl sm:w-28">
<BlogThumbnail
post={post}
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
className="aspect-square rounded-2xl"
/>
</Link>
<div className="min-w-0 flex-1">
<Link to={blogPostPath(post.slug)} className="font-medium text-primary hover:underline"> <Link to={blogPostPath(post.slug)} className="font-medium text-primary hover:underline">
{post.title} {post.title}
</Link> </Link>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{post.excerpt || "بدون خلاصه"}</p> <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{post.excerpt || "بدون خلاصه"}</p>
</div> </div>
</div>
</div>
); );
const renderCommentRow = (comment: Types.CommentSchema) => ( const renderCommentRow = (comment: Types.CommentSchema) => (
@@ -450,7 +476,7 @@ export default function Profile() {
<div className="bg-background px-4 py-8 md:py-10" dir="rtl"> <div className="bg-background px-4 py-8 md:py-10" dir="rtl">
<div className="container mx-auto max-w-6xl space-y-6"> <div className="container mx-auto max-w-6xl space-y-6">
{(loading || fetching) && !me ? ( {(loading || fetching) && !me ? (
<div className="flex min-h-[50vh] items-center justify-center gap-3 text-muted-foreground"> <div className="flex min-h-[75vh] items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span>در حال بارگذاری پروفایل...</span> <span>در حال بارگذاری پروفایل...</span>
</div> </div>
@@ -650,32 +676,46 @@ export default function Profile() {
<CardContent> <CardContent>
<div className="flex flex-col gap-4 lg:flex-row-reverse"> <div className="flex flex-col gap-4 lg:flex-row-reverse">
<div className="min-w-0 flex-1 space-y-3"> <div className="min-w-0 flex-1 space-y-3">
{blogTab === "liked" ? ( {blogActivityLoading ? (
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground flex flex-col justify-center h-full">
<Loader2 className="mx-auto mb-3 h-5 w-5 animate-spin text-primary" />
در حال دریافت فعالیتهای بلاگ...
</div>
) : null}
{blogActivityError ? (
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground flex flex-col justify-center h-full">
دریافت فعالیتهای بلاگ ناموفق بود.
<Button
type="button"
variant="outline"
size="sm"
className="mx-auto mt-3 rounded-full"
onClick={() => void refetchBlogActivity()}
>
تلاش دوباره
</Button>
</div>
) : null}
{showBlogActivityTabs && blogTab === "liked" ? (
blogActivity?.liked_posts.length ? blogActivity.liked_posts.map(renderPostRow) : ( blogActivity?.liked_posts.length ? blogActivity.liked_posts.map(renderPostRow) : (
<EmptyBlogActivity icon={Heart} text="هنوز پستی را نپسندیده‌اید." /> <EmptyBlogActivity icon={Heart} text="هنوز پستی را نپسندیده‌اید." />
) )
) : null} ) : null}
{blogTab === "saved" ? ( {showBlogActivityTabs && blogTab === "saved" ? (
blogActivity?.saved_posts.length ? blogActivity.saved_posts.map(renderPostRow) : ( blogActivity?.saved_posts.length ? blogActivity.saved_posts.map(renderPostRow) : (
<EmptyBlogActivity icon={Bookmark} text="هنوز پستی را ذخیره نکرده‌اید." /> <EmptyBlogActivity icon={Bookmark} text="هنوز پستی را ذخیره نکرده‌اید." />
) )
) : null} ) : null}
{blogTab === "comments" ? ( {showBlogActivityTabs && blogTab === "comments" ? (
blogActivity?.comments.length ? blogActivity.comments.map(renderCommentRow) : ( blogCommentActivity.length ? blogCommentActivity.map(renderCommentRow) : (
<EmptyBlogActivity icon={MessageSquareText} text="هنوز نظری ثبت نکرده‌اید." /> <EmptyBlogActivity icon={MessageSquareText} text="هنوز نظری ثبت نکرده‌اید." />
) )
) : null} ) : null}
{blogTab === "replies" ? (
blogActivity?.replies.length ? blogActivity.replies.map(renderCommentRow) : (
<EmptyBlogActivity icon={Reply} text="هنوز پاسخی ثبت نکرده‌اید." />
)
) : null}
</div> </div>
<div className="flex gap-2 overflow-x-auto pb-1 lg:w-60 lg:flex-col lg:overflow-visible lg:pb-0"> <div className="flex gap-2 overflow-x-auto pb-1 lg:w-60 lg:flex-col lg:overflow-visible lg:pb-0">
<TabButton value="liked" active={blogTab} label="پسندیده‌ها" count={blogCounts.liked} onClick={(value) => setBlogTab(value as BlogTab)} /> <TabButton value="liked" active={blogTab} label="پسندیده‌ها" count={blogCounts.liked} onClick={(value) => setBlogTab(value as BlogTab)} />
<TabButton value="saved" active={blogTab} label="ذخیره‌شده‌ها" count={blogCounts.saved} onClick={(value) => setBlogTab(value as BlogTab)} /> <TabButton value="saved" active={blogTab} label="ذخیره‌شده‌ها" count={blogCounts.saved} onClick={(value) => setBlogTab(value as BlogTab)} />
<TabButton value="comments" active={blogTab} label="نظرها" count={blogCounts.comments} onClick={(value) => setBlogTab(value as BlogTab)} /> <TabButton value="comments" active={blogTab} label="نظرات" count={blogCounts.comments} onClick={(value) => setBlogTab(value as BlogTab)} />
<TabButton value="replies" active={blogTab} label="پاسخ‌ها" count={blogCounts.replies} onClick={(value) => setBlogTab(value as BlogTab)} />
</div> </div>
</div> </div>
</CardContent> </CardContent>