sync from local backup
This commit is contained in:
@@ -0,0 +1,658 @@
|
||||
import "./ArticleDetail.css";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Tag, Avatar, Button, Tooltip, Divider } from "antd";
|
||||
import { message } from "@/utils/message";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
EyeOutlined,
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
CommentOutlined,
|
||||
StarOutlined,
|
||||
ShareAltOutlined,
|
||||
BookOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
HeartOutlined,
|
||||
HeartFilled,
|
||||
StarFilled,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { api_request } from "@/utils/request";
|
||||
import { useRecordHistory } from "@/hooks/useRecordHistory";
|
||||
import { formatDateTime } from "@/utils/time";
|
||||
|
||||
const ArticleDetail: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [article, setArticle] = useState<any>(null);
|
||||
const [comments, setComments] = useState<any[]>([]);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(0);
|
||||
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [readProgress, setReadProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [commentPage, setCommentPage] = useState(1);
|
||||
const [commentsPagination, setCommentsPagination] = useState<any>(null);
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
const [replyTarget, setReplyTarget] = useState<{ commentId: number; replyTo: string } | null>(null);
|
||||
const [showMentionList, setShowMentionList] = useState(false);
|
||||
const [showReplyMentionList, setShowReplyMentionList] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [mentionTarget, setMentionTarget] = useState<"comment" | "reply">("comment");
|
||||
|
||||
const mentionUsers = React.useMemo(() => {
|
||||
const users: { id: number; name: string; avatar: string; isAuthor?: boolean }[] = [];
|
||||
if (article) {
|
||||
users.push({ id: article.author_user_id, name: article.author_name, avatar: article.author_avatar, isAuthor: true });
|
||||
}
|
||||
const seen = new Set(users.map(u => u.id));
|
||||
const extractFromComments = (list: any[]) => {
|
||||
for (const c of list) {
|
||||
if (c.user_id && !seen.has(c.user_id)) {
|
||||
seen.add(c.user_id);
|
||||
users.push({ id: c.user_id, name: c.user_name, avatar: c.user_avatar });
|
||||
}
|
||||
if (c.replies?.length) extractFromComments(c.replies);
|
||||
}
|
||||
};
|
||||
extractFromComments(comments);
|
||||
return users;
|
||||
}, [article, comments]);
|
||||
|
||||
const filteredMentionUsers = mentionUsers.filter(u =>
|
||||
u.name.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||
);
|
||||
|
||||
useRecordHistory(article ? {
|
||||
type: "article",
|
||||
title: article.title || "",
|
||||
description: article.excerpt || "",
|
||||
image: article.image || "",
|
||||
category: article.category || "",
|
||||
link: `/article/${id}`,
|
||||
} : null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadArticle = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api_request.article.get_detail(Number(id));
|
||||
const data = (res as any)?.data;
|
||||
if (data) {
|
||||
setArticle(data);
|
||||
setLiked(data.is_liked || false);
|
||||
setFavorited(data.is_favorited || false);
|
||||
setLikeCount(data.likes || 0);
|
||||
setFavoriteCount(data.favorites_count || 0);
|
||||
setComments(data.comments || []);
|
||||
setCommentsPagination(data.comments_pagination || null);
|
||||
setCommentPage(1);
|
||||
setFollowing(data.is_following || false);
|
||||
}
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadArticle();
|
||||
}, [id]);
|
||||
|
||||
const detectMention = (text: string, cursorPos: number | null): boolean => {
|
||||
if (cursorPos === null) return false;
|
||||
const beforeCursor = text.slice(0, cursorPos);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
if (atIndex === -1) return false;
|
||||
const afterAt = beforeCursor.slice(atIndex + 1);
|
||||
if (afterAt.includes(" ") || afterAt.length > 10) return false;
|
||||
setMentionQuery(afterAt);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCommentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setCommentText(val);
|
||||
const pos = e.target.selectionStart;
|
||||
const isMention = detectMention(val, pos);
|
||||
setShowMentionList(isMention);
|
||||
setMentionTarget("comment");
|
||||
};
|
||||
|
||||
const handleReplyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setReplyText(val);
|
||||
const pos = e.target.selectionStart;
|
||||
const isMention = detectMention(val, pos);
|
||||
setShowReplyMentionList(isMention);
|
||||
setMentionTarget("reply");
|
||||
};
|
||||
|
||||
const insertMention = (user: { id: number; name: string; avatar: string; isAuthor?: boolean }) => {
|
||||
const tag = `@${user.name} `;
|
||||
if (mentionTarget === "comment") {
|
||||
const pos = commentText.lastIndexOf("@");
|
||||
const newText = commentText.slice(0, pos) + tag;
|
||||
setCommentText(newText);
|
||||
setShowMentionList(false);
|
||||
} else {
|
||||
const pos = replyText.lastIndexOf("@");
|
||||
const newText = replyText.slice(0, pos) + tag;
|
||||
setReplyText(newText);
|
||||
setShowReplyMentionList(false);
|
||||
}
|
||||
setMentionQuery("");
|
||||
};
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0;
|
||||
setReadProgress(progress);
|
||||
setShowBackToTop(scrollTop > 400);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
if (hash && hash.startsWith("#comment-")) {
|
||||
const timer = setTimeout(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.style.transition = "box-shadow 0.3s";
|
||||
el.style.boxShadow = "0 0 0 2px #667eea, 0 4px 16px rgba(102, 126, 234, 0.25)";
|
||||
setTimeout(() => {
|
||||
el.style.boxShadow = "";
|
||||
}, 2500);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await api_request.article.toggle_like(Number(id));
|
||||
const data = (res as any)?.data;
|
||||
if (data) {
|
||||
setLiked(data.liked);
|
||||
setLikeCount(data.likes_count);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await api_request.article.toggle_favorite(Number(id));
|
||||
const data = (res as any)?.data;
|
||||
if (data) {
|
||||
setFavorited(data.favorited);
|
||||
setFavoriteCount(data.favorites_count);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!article || !article.author_user_id) return;
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
const res = await api_request.user.follow(article.author_user_id);
|
||||
const data = (res as any)?.data;
|
||||
if (data) {
|
||||
setFollowing(data.is_following);
|
||||
}
|
||||
} catch {} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentLike = async (commentId: number) => {
|
||||
try {
|
||||
const res = await api_request.article.toggle_comment_like(commentId);
|
||||
const data = (res as any)?.data;
|
||||
if (data) {
|
||||
const updateCommentLikes = (list: any[]): any[] =>
|
||||
list.map((c: any) => {
|
||||
if (c.id === commentId) {
|
||||
return { ...c, is_liked: data.liked, likes: data.likes_count };
|
||||
}
|
||||
if (c.replies?.length) {
|
||||
return { ...c, replies: updateCommentLikes(c.replies) };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setComments(updateCommentLikes(comments));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
message.success(t('articles.comments.copied'));
|
||||
};
|
||||
|
||||
const handleComment = async () => {
|
||||
if (!commentText.trim() || !id) return;
|
||||
try {
|
||||
const res = await api_request.article.create_comment(Number(id), { content: commentText });
|
||||
if ((res as any)?.code === 10000) {
|
||||
setCommentText('');
|
||||
const newComment = (res as any)?.data;
|
||||
if (newComment) {
|
||||
setComments(prev => [newComment, ...prev]);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadMoreComments = async () => {
|
||||
if (!id || !commentsPagination?.next || commentsLoading) return;
|
||||
setCommentsLoading(true);
|
||||
try {
|
||||
const nextPage = commentPage + 1;
|
||||
const res = await api_request.article.get_comments(Number(id), { page: nextPage });
|
||||
const data = (res as any)?.data;
|
||||
if (data?.results) {
|
||||
setComments(prev => [...prev, ...data.results]);
|
||||
setCommentsPagination({ next: data.next, count: data.count });
|
||||
setCommentPage(nextPage);
|
||||
}
|
||||
} catch {} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (commentId: number) => {
|
||||
if (!replyText.trim()) {
|
||||
message.warning(t('articles.comments.emptyReply'));
|
||||
return;
|
||||
}
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await api_request.article.create_comment(Number(id), {
|
||||
content: replyText,
|
||||
parent: commentId,
|
||||
});
|
||||
if ((res as any)?.code === 10000) {
|
||||
message.success(t('articles.comments.replySuccess'));
|
||||
setReplyText("");
|
||||
setReplyTarget(null);
|
||||
const newReply = (res as any)?.data;
|
||||
if (newReply) {
|
||||
setComments(prev => prev.map(c => {
|
||||
if (c.id === commentId) {
|
||||
return {
|
||||
...c,
|
||||
replies: [...(c.replies || []), newReply]
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
if (loading || !article) {
|
||||
return <div className="article-detail-page" style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="article-detail-page">
|
||||
<div className="read-progress-bar" style={{ width: `${readProgress}%` }} />
|
||||
<div className="article-cover-wrapper">
|
||||
<img
|
||||
className="article-cover-img"
|
||||
src={article.image}
|
||||
alt={article.title}
|
||||
/>
|
||||
<div className="article-cover-overlay">
|
||||
<div className="article-cover-content">
|
||||
<h1 className="article-cover-title">{article.title}</h1>
|
||||
<div className="article-cover-meta">
|
||||
<div className="author-info">
|
||||
<Avatar
|
||||
src={article.author_avatar}
|
||||
size={32}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
/>
|
||||
<span
|
||||
className="author-name"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
>
|
||||
{article.author_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<ClockCircleOutlined />
|
||||
<span>{formatDateTime(article.publish_date, i18n.language)}</span>
|
||||
</div>
|
||||
{article.update_date !== article.publish_date && (
|
||||
<div className="meta-item">
|
||||
<ClockCircleOutlined />
|
||||
<span>{t('articles.detail.updated')}: {formatDateTime(article.update_date, i18n.language)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="meta-item">
|
||||
<BookOutlined />
|
||||
<span>{article.read_time}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<EyeOutlined />
|
||||
<span>{article.views?.toLocaleString()} {t('articles.detail.read')}</span>
|
||||
</div>
|
||||
<Tag color="purple">{article.category}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="article-action-bar">
|
||||
<div className="article-action-bar-inner">
|
||||
<button className="back-btn" onClick={() => navigate(-1)}>
|
||||
<ArrowLeftOutlined />
|
||||
<span>{t('articles.actions.back')}</span>
|
||||
</button>
|
||||
<div className="article-action-buttons">
|
||||
<Tooltip title={liked ? t('articles.actions.unlike') : t('articles.actions.like')}>
|
||||
<button
|
||||
className={`action-btn ${liked ? "liked" : ""}`}
|
||||
onClick={handleLike}
|
||||
>
|
||||
{liked ? <HeartFilled /> : <HeartOutlined />}
|
||||
<span className="count">{likeCount}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={favorited ? t('articles.actions.unfavorite') : t('articles.actions.favorite')}>
|
||||
<button
|
||||
className={`action-btn ${favorited ? "favorited" : ""}`}
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
{favorited ? <StarFilled /> : <StarOutlined />}
|
||||
<span className="count">{favoriteCount}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('articles.actions.share')}>
|
||||
<button className="action-btn" onClick={handleShare}>
|
||||
<ShareAltOutlined />
|
||||
<span className="count">{t('articles.actions.share')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('articles.actions.comment')}>
|
||||
<button className="action-btn">
|
||||
<CommentOutlined />
|
||||
<span className="count">{article.comments_count}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="article-body-card">
|
||||
<div className="article-body" dangerouslySetInnerHTML={{ __html: article.content }} />
|
||||
|
||||
{article.related_articles && article.related_articles.length > 0 && (
|
||||
<div className="related-articles-section">
|
||||
<h3 className="related-articles-title">{t('articles.detail.relatedArticles')}</h3>
|
||||
<div className="related-articles-grid">
|
||||
{article.related_articles.map((related: any) => (
|
||||
<div
|
||||
key={related.id}
|
||||
className="related-article-card"
|
||||
onClick={() => navigate(`/article/${related.id}`)}
|
||||
>
|
||||
<div className="related-article-cover">
|
||||
<img src={related.image} alt={related.title} />
|
||||
</div>
|
||||
<div className="related-article-info">
|
||||
<h4 className="related-article-title">{related.title}</h4>
|
||||
<span className="related-article-views">
|
||||
<EyeOutlined /> {related.views || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider className="article-divider" />
|
||||
|
||||
<div className="author-card">
|
||||
<div className="author-card-avatar">
|
||||
<Avatar
|
||||
src={article.author_avatar}
|
||||
size={64}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="author-card-info">
|
||||
<div
|
||||
className="author-card-name"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
>
|
||||
{article.author_name}
|
||||
</div>
|
||||
<div className="author-card-bio">
|
||||
{article.author_bio}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
className="follow-btn"
|
||||
ghost={following}
|
||||
loading={followLoading}
|
||||
onClick={handleFollow}
|
||||
>
|
||||
{following ? t('articles.detail.following') : t('articles.detail.follow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="comment-section">
|
||||
<div className="section-title">
|
||||
{t('articles.comments.title')} ({article.comments_count})
|
||||
</div>
|
||||
<div className="comment-input-area">
|
||||
<div className="comment-input-hint">
|
||||
{t('articles.comments.mentionHint')}
|
||||
</div>
|
||||
<div className="mention-wrapper">
|
||||
<textarea
|
||||
placeholder={t('articles.comments.placeholder')}
|
||||
value={commentText}
|
||||
onChange={handleCommentChange}
|
||||
/>
|
||||
{showMentionList && filteredMentionUsers.length > 0 && (
|
||||
<div className="mention-dropdown">
|
||||
{filteredMentionUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="mention-dropdown-item"
|
||||
onClick={() => insertMention(user)}
|
||||
>
|
||||
<Avatar src={user.avatar} size={28} />
|
||||
<span className="mention-user-name">{user.name}</span>
|
||||
{user.isAuthor && <span className="mention-author-tag">{t('articles.comments.author')}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="comment-input-actions">
|
||||
<Button type="primary" onClick={handleComment}>
|
||||
{t('articles.comments.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="comment-list">
|
||||
{comments.map((comment: any) => (
|
||||
<div className="comment-item" key={comment.id} id={`comment-${comment.id}`}>
|
||||
<Avatar
|
||||
src={comment.user_avatar}
|
||||
size={40}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => comment.user_id && navigate(`/user/${comment.user_id}`)}
|
||||
/>
|
||||
<div className="comment-content">
|
||||
<div className="comment-header">
|
||||
<span
|
||||
className="comment-author"
|
||||
style={{ cursor: comment.user_id ? "pointer" : "default" }}
|
||||
onClick={() => comment.user_id && navigate(`/user/${comment.user_id}`)}
|
||||
>
|
||||
{comment.user_name}
|
||||
</span>
|
||||
{comment.user_location && (
|
||||
<span className="comment-location">{comment.user_location}</span>
|
||||
)}
|
||||
<span className="comment-time">{formatDateTime(comment.created_at, i18n.language)}</span>
|
||||
</div>
|
||||
<div className="comment-text">{comment.content}</div>
|
||||
<div className="comment-actions">
|
||||
<span
|
||||
className={`comment-action ${comment.is_liked ? "liked" : ""}`}
|
||||
onClick={() => handleCommentLike(comment.id)}
|
||||
>
|
||||
{comment.is_liked ? <LikeFilled /> : <LikeOutlined />} {comment.likes}
|
||||
</span>
|
||||
<span
|
||||
className="comment-action"
|
||||
onClick={() => setReplyTarget({ commentId: comment.id, replyTo: comment.user_name })}
|
||||
>
|
||||
<CommentOutlined /> {t('articles.comments.reply')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{comment.replies?.length > 0 && (
|
||||
<div className="comment-replies">
|
||||
{comment.replies.map((reply: any) => (
|
||||
<div className="reply-item" key={reply.id}>
|
||||
<Avatar
|
||||
src={reply.user_avatar}
|
||||
size={32}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => reply.user_id && navigate(`/user/${reply.user_id}`)}
|
||||
/>
|
||||
<div className="reply-content">
|
||||
<div className="reply-header">
|
||||
<span
|
||||
className="reply-author"
|
||||
style={{ cursor: reply.user_id ? "pointer" : "default" }}
|
||||
onClick={() => reply.user_id && navigate(`/user/${reply.user_id}`)}
|
||||
>
|
||||
{reply.user_name}
|
||||
</span>
|
||||
{reply.user_location && (
|
||||
<span className="reply-location">{reply.user_location}</span>
|
||||
)}
|
||||
{reply.parent && (
|
||||
<span className="reply-to">{t('articles.comments.replyTo')} <strong>{reply.parent_user_name || comment.user_name}</strong></span>
|
||||
)}
|
||||
<span className="reply-time">{formatDateTime(reply.created_at, i18n.language)}</span>
|
||||
</div>
|
||||
<div className="reply-text">{reply.content}</div>
|
||||
<div className="reply-actions">
|
||||
<span
|
||||
className={`comment-action ${reply.is_liked ? "liked" : ""}`}
|
||||
onClick={() => handleCommentLike(reply.id)}
|
||||
>
|
||||
{reply.is_liked ? <LikeFilled /> : <LikeOutlined />} {reply.likes}
|
||||
</span>
|
||||
<span
|
||||
className="comment-action"
|
||||
onClick={() => setReplyTarget({ commentId: comment.id, replyTo: reply.user_name })}
|
||||
>
|
||||
<CommentOutlined /> {t('articles.comments.reply')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replyTarget?.commentId === comment.id && (
|
||||
<div className="reply-input-area">
|
||||
<div className="reply-input-header">
|
||||
{t('articles.comments.reply')} <strong>{replyTarget?.replyTo}</strong>
|
||||
<span className="reply-input-close" onClick={() => { setReplyTarget(null); setReplyText(""); }}>×</span>
|
||||
</div>
|
||||
<div className="mention-wrapper">
|
||||
<textarea
|
||||
className="reply-input-textarea"
|
||||
placeholder={`${t('articles.comments.replyPlaceholder')} ${replyTarget?.replyTo}...`}
|
||||
value={replyText}
|
||||
onChange={handleReplyChange}
|
||||
autoFocus
|
||||
/>
|
||||
{showReplyMentionList && filteredMentionUsers.length > 0 && (
|
||||
<div className="mention-dropdown">
|
||||
{filteredMentionUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="mention-dropdown-item"
|
||||
onClick={() => insertMention(user)}
|
||||
>
|
||||
<Avatar src={user.avatar} size={24} />
|
||||
<span className="mention-user-name">{user.name}</span>
|
||||
{user.isAuthor && <span className="mention-author-tag">{t('articles.comments.author')}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="reply-input-actions">
|
||||
<Button size="small" onClick={() => { setReplyTarget(null); setReplyText(""); }}>{t('articles.comments.cancel')}</Button>
|
||||
<Button type="primary" size="small" onClick={() => handleReply(comment.id)}>{t('articles.comments.reply')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{commentsPagination && commentsPagination.next && (
|
||||
<div className="comment-load-more">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => loadMoreComments()}
|
||||
loading={commentsLoading}
|
||||
>
|
||||
{t('articles.comments.loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`back-to-top ${showBackToTop ? "visible" : ""}`}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleDetail;
|
||||
Reference in New Issue
Block a user