Files
neo-blog/web/src/components/comment/comment-item.tsx
Snowykami 4fb39110ad
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 10s
feat: 优化评论功能,添加登录提示和国际化支持,重构相关组件
2025-09-09 23:21:08 +08:00

242 lines
8.9 KiB
TypeScript

import { useToLogin, useToUserProfile } from "@/hooks/use-route";
import { User } from "@/models/user";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar";
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
import { Comment } from "@/models/comment";
import { TargetType } from "@/models/types";
import { toggleLike } from "@/api/like";
import { useDoubleConfirm } from "@/hooks/use-double-confirm";
import { CommentInput } from "./comment-input";
import { createComment, deleteComment, listComments, updateComment } from "@/api/comment";
import { OrderBy } from "@/models/common";
import config from "@/config";
export function CommentItem(
{
user,
comment,
parentComment,
onCommentDelete,
}: {
user: User | null,
comment: Comment,
parentComment: Comment | null,
onCommentDelete: ({ commentId }: { commentId: number }) => void,
}
) {
const t = useTranslations("Comment")
const commonT = useTranslations('Common')
const clickToUserProfile = useToUserProfile();
const clickToLogin = useToLogin();
const { confirming, onClick, onBlur } = useDoubleConfirm();
const [likeCount, setLikeCount] = useState(comment.likeCount);
const [liked, setLiked] = useState(comment.isLiked);
const [isPrivate, setIsPrivate] = useState(comment.isPrivate);
const [replyCount, setReplyCount] = useState(comment.replyCount);
const [showReplies, setShowReplies] = useState(false);
const [showReplyInput, setShowReplyInput] = useState(false);
const [replies, setReplies] = useState<Comment[]>([]);
const [repliesLoaded, setRepliesLoaded] = useState(false);
const [showEditInput, setShowEditInput] = useState(false);
const handleToggleLike = () => {
if (!user) {
toast.error(t("login_required"), {
action: <div className="flex justify-end">
<button
onClick={clickToLogin}
className="ml-0 text-left bg-red-400 text-white dark:text-black px-3 py-1 rounded font-semibold hover:bg-red-600 transition-colors"
>
{commonT("login")}
</button>
</div>,
});
return;
}
toggleLike(
{ targetType: TargetType.Comment, targetId: comment.id }
).then(res => {
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
setLiked(res.data.status);
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
}).catch(error => {
toast.error(t("like_failed") + ": " + error.message);
});
}
const reloadReplies = () => {
listComments(
{
targetType: comment.targetType,
targetId: comment.targetId,
depth: comment.depth + 1,
orderBy: OrderBy.CreatedAt,
desc: false,
page: 1,
size: config.commentsPerPage,
commentId: comment.id
}
).then(response => {
setReplies(response.data);
setRepliesLoaded(true);
});
}
const toggleReplies = () => {
if (!showReplies && !repliesLoaded) {
reloadReplies();
}
setShowReplies(!showReplies);
}
const onCommentEdit = ({ commentContent: newContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
updateComment({ id: comment.id, content: newContent, isPrivate }).then(() => {
toast.success(t("edit_success"));
comment.content = newContent;
setIsPrivate(isPrivate);
setShowEditInput(false);
}).catch(error => {
toast.error(t("edit_failed") + ": " + error.message);
});
}
const onReply = ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
createComment({
targetType: comment.targetType,
targetId: comment.targetId,
content: commentContent,
replyId: comment.id,
isPrivate,
}).then(() => {
toast.success(t("comment_success"));
reloadReplies();
setShowReplies(true);
setShowReplyInput(false);
setReplyCount(replyCount + 1);
}).catch(error => {
toast.error(t("comment_failed") + ": " +
error?.response?.data?.message || error?.message
);
});
}
const onReplyDelete = ({ commentId: replyId }: { commentId: number }) => {
deleteComment({ id: replyId }).then(() => {
toast.success(t("delete_success"));
setReplyCount(replyCount - 1);
setReplies(replies.filter(r => r.id !== replyId));
}).catch(error => {
toast.error(t("delete_failed") + ": " + error.message);
});
}
return (
<div className="flex">
<div className="fade-in">
{getGravatarByUser(comment.user)}
</div>
<div className="flex-1 pl-2 fade-in-up">
<div className="font-bold text-base text-slate-800 dark:text-slate-100 fade-in-up">{comment.user.nickname}</div>
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
{
isPrivate && <Lock className="inline w-4 h-4 mr-1 mb-1 text-slate-500 dark:text-slate-400" />
}
{
parentComment &&
<>{t("reply")} <button onClick={() => clickToUserProfile(parentComment.user.nickname)} className="text-primary">{parentComment?.user.nickname}</button>: </>
}
{comment.content}
</p>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
<span>{new Date(comment.updatedAt).toLocaleString()}</span>
{/* 点赞按钮 */}
<button
title={t(liked ? "unlike" : "like")}
onClick={handleToggleLike}
className={`flex items-center justify-center px-2 py-1 h-5 gap-1 text-xs rounded
${liked ? 'bg-primary ' : 'bg-slate-400 hover:bg-slate-600'}
text-primary-foreground dark:text-white dark:hover:bg-slate-500 fade-in`}
>
<Heart className="w-3 h-3" /> <div>{likeCount}</div>
</button>
{/* 回复按钮 */}
<button
title={t("reply")}
onClick={() => { setShowReplyInput(!showReplyInput); setShowEditInput(false); }}
className={`flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs
rounded ${showReplyInput ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}>
<Reply className="w-3 h-3" />
</button>
{/* 编辑和删除按钮 仅自己的评论可见 */}
{user?.id === comment.user.id && (
<>
<button
title={t("edit")}
className={`
flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs
rounded ${showEditInput ? "bg-slate-600" : "bg-slate-400"} hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up`}
onClick={() => { setShowEditInput(!showEditInput); setShowReplyInput(false); }}
>
<Pencil className="w-3 h-3" />
</button>
<button
title={t("delete")}
className={`flex items-center justify-center px-2 py-1 h-5 rounded
text-primary-foreground dark:text-white text-xs
${confirming ? 'bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600' : 'bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500'} fade-in`}
onClick={() => onClick(() => { onCommentDelete({ commentId: comment.id }); })}
onBlur={onBlur}
>
<Trash className="w-3 h-3" />
{confirming && (
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
)}
</button>
</>
)}
{replyCount > 0 &&
<button onClick={toggleReplies} className="fade-in-up">
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
</button>
}
</div>
{/* 这俩输入框一次只能显示一个 */}
{showReplyInput && !showEditInput && <CommentInput
user={user}
onCommentSubmitted={onReply}
/>}
{showEditInput && !showReplyInput && <CommentInput
user={user}
initContent={comment.content}
initIsPrivate={isPrivate}
onCommentSubmitted={onCommentEdit}
isUpdate={true}
/>}
{showReplies && replies.length > 0 && (
<div className="mt-4 pl-4 border-l border-slate-300 dark:border-slate-600 space-y-4">
{replies.map((reply) => (
<CommentItem
key={reply.id}
user={reply.user}
comment={reply}
parentComment={comment}
onCommentDelete={onReplyDelete}
/>
))}
</div>
)}
</div>
</div >
)
}