Refactor comment system:

- Update comment API to handle private comments and improve request structure.
- Remove unused CSS animations and components related to comments.
- Implement new comment input and item components with enhanced functionality including editing and private comment options.
- Integrate user profile navigation and improve user experience with better feedback on actions (like, delete, edit).
- Update localization for new features and ensure consistency in comment handling.
- Introduce checkbox for private comments in the comment input.
This commit is contained in:
2025-09-09 21:56:41 +08:00
parent ad9dfb0c4c
commit 3e70d63e70
16 changed files with 523 additions and 666 deletions

View File

@ -1,47 +0,0 @@
/* 评论区原生动画:淡入、上移 */
.fade-in {
opacity: 0;
animation: fadeIn 0.5s ease forwards;
}
.fade-in-up {
opacity: 0;
transform: translateY(16px);
animation: fadeInUp 0.5s cubic-bezier(.33,1,.68,1) forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}/* 评论区原生动画:淡入、上移 */
.fade-in {
opacity: 0;
animation: fadeIn 0.5s ease forwards;
}
.fade-in-up {
opacity: 0;
transform: translateY(16px);
animation: fadeInUp 0.5s cubic-bezier(.33,1,.68,1) forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1,86 +0,0 @@
"use client";
import { Textarea } from "@/components/ui/textarea"
import { getGravatarByUser } from "@/components/common/gravatar"
import { toast } from "sonner";
import { useState, useEffect } from "react";
import type { User } from "@/models/user";
import { getLoginUser } from "@/api/user";
import { createComment } from "@/api/comment";
import { CircleUser } from "lucide-react";
import { useTranslations } from "next-intl";
import { TargetType } from "@/models/types";
import { useToLogin } from "@/hooks/use-route";
import NeedLogin from "../common/need-login";
import "./comment-animations.css";
export function CommentInput(
{ targetId, targetType, replyId, onCommentSubmitted }: { targetId: number, targetType: TargetType, replyId: number | null, onCommentSubmitted: () => void }
) {
const t = useTranslations('Comment')
const toLogin = useToLogin()
const [user, setUser] = useState<User | null>(null);
const [commentContent, setCommentContent] = useState("");
useEffect(() => {
getLoginUser()
.then(response => {
setUser(response.data);
})
}, []);
const handleCommentSubmit = async () => {
if (!user) {
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
return;
}
if (!commentContent.trim()) {
toast.error(t("content_required"));
return;
}
await createComment({
targetType: targetType,
targetId: targetId,
content: commentContent,
replyId: replyId,
isPrivate: false,
}).then(response => {
setCommentContent("");
toast.success(t("comment_success"));
onCommentSubmitted();
}).catch(error => {
toast.error(t("comment_failed") + ": " +
error?.response?.data?.message || error?.message
);
});
};
return (
<div className="fade-in-up">
<div className="flex py-4 fade-in">
{/* Avatar */}
<div onClick={user ? undefined : toLogin} className="flex-shrink-0 w-10 h-10 fade-in">
{user && getGravatarByUser(user)}
{!user && <CircleUser className="w-full h-full fade-in" />}
</div>
{/* Input Area */}
<div className="flex-1 pl-2 fade-in-up">
<Textarea
placeholder={t("placeholder")}
className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end fade-in-up">
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
{t("submit")}
</button>
</div>
</div>
);
}

View File

@ -1,173 +0,0 @@
"use client";
import type { Comment } from "@/models/comment";
import { getGravatarByUser } from "@/components/common/gravatar";
import { useEffect, useState } from "react";
import { Reply, Trash } from "lucide-react";
import { toggleLike } from "@/api/like";
import { TargetType } from "@/models/types";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { CommentInput } from "./comment-input";
import { deleteComment, listComments } from "@/api/comment";
import { OrderBy } from "@/models/common";
import { getLoginUser } from "@/api/user";
import type { User } from "@/models/user";
import Link from "next/link";
import "./comment-animations.css";
export function CommentItem({comment, parentComment, onCommentDelete}:{comment: Comment, parentComment: Comment | null, onCommentDelete: () => void}) {
const t = useTranslations("Comment")
const [user, setUser] = useState<User | null>(null);
const [liked, setLiked] = useState(comment.isLiked);
const [likeCount, setLikeCount] = useState(comment.likeCount);
const [replyCount, setReplyCount] = useState(comment.replyCount);
const [showReplyInput, setShowReplyInput] = useState(false);
const [showReplies, setShowReplies] = useState(false);
// 二次确认删除
const [deleteConfirm, setDeleteConfirm] = useState(false);
useEffect(() => {
getLoginUser()
.then(response => {
setUser(response.data);
})
}, []);
const handleToggleLike = () => {
toggleLike({ targetType: TargetType.Comment, targetId: comment.id })
.then(res => {
setLiked(res.data.status);
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
})
.catch(error => {
toast.error(t("like_failed") + ": " + error.message);
});
}
const handleDeleteComment = (id: number) => {
deleteComment(id)
.then(() => {
toast.success(t("delete_success"));
onCommentDelete();
})
.catch(error => {
toast.error(t("delete_failed") + ": " + error.message);
});
}
const onReplySubmitted = () => {
setReplyCount(replyCount + 1);
setShowReplyInput(false);
setShowReplies(true);
}
return (
<div className="flex fade-in-up">
<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">
{parentComment && <>{t("reply")} <Link href={`/u/${parentComment.user.username}`} className="text-primary">{parentComment?.user.nickname}</Link>: </>}
{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
onClick={handleToggleLike}
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded
${liked ? 'bg-primary text-primary-foreground dark:text-white' : 'bg-slate-400 hover:bg-slate-600'}
dark:hover:bg-slate-500 fade-in`}
>
👍 {likeCount}
</button>
<button onClick={() => setShowReplyInput(!showReplyInput)}
className="flex items-center justify-center px-2 py-1 h-5
text-primary-foreground dark:text-white text-xs
rounded bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up">
<Reply className="w-3 h-3" />
</button>
{comment.user.id === user?.id && (
deleteConfirm ? (
<button
onClick={() => handleDeleteComment(comment.id)}
className="flex items-center justify-center px-2 py-1 h-5 text-primary-foreground dark:text-white text-xs rounded bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 fade-in"
onBlur={() => setDeleteConfirm(false)}
title={t("confirm_delete")}
>
{t("confirm_delete")}
</button>
) : (
<button
onClick={() => setDeleteConfirm(true)}
className="flex items-center justify-center px-2 py-1 h-5 text-primary-foreground dark:text-white text-xs rounded bg-slate-400 hover:bg-red-600 dark:hover:bg-red-500 fade-in"
title={t("delete")}
>
<Trash className="w-3 h-3" />
</button>
)
)}
{replyCount > 0 &&
<button onClick={() => setShowReplies(!showReplies)} className="fade-in-up">
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
</button>
}
</div>
{showReplyInput && <CommentInput targetId={comment.targetId} targetType={comment.targetType} replyId={comment.id} onCommentSubmitted={onReplySubmitted} />}
{showReplies && replyCount > 0 && <RepliesList parentComment={comment} />}
</div>
</div>
);
}
// 一个评论的回复区域组件
function RepliesList({ parentComment }: { parentComment: Comment }) {
const t = useTranslations("Comment")
const [replies, setReplies] = useState<Comment[]>([]);
useEffect(() => {
listComments({
targetType: parentComment.targetType,
targetId: parentComment.targetId,
commentId: parentComment.id,
depth: parentComment.depth + 1,
orderBy: OrderBy.CreatedAt,
desc: false,
page: 1,
size: 9999,
}).then(res => {
setReplies(res.data);
}).catch(error => {
toast.error(t("load_replies_failed") + ": " + error.message);
});
}, [parentComment])
const onCommentDelete = () => {
listComments({
targetType: parentComment.targetType,
targetId: parentComment.targetId,
commentId: parentComment.id,
depth: parentComment.depth + 1,
orderBy: OrderBy.CreatedAt,
desc: false,
page: 1,
size: 9999,
}).then(res => {
setReplies(res.data);
}).catch(error => {
toast.error(t("load_replies_failed") + ": " + error.message);
});
}
return (
<div className="mt-4 border-l border-slate-300 pl-4">
{replies.map(reply => (
<div key={reply.id} className="mb-4">
<CommentItem comment={reply} parentComment={parentComment} onCommentDelete={onCommentDelete} />
</div>
))}
</div>
)
}

View File

@ -1,94 +0,0 @@
"use client";
import type { Comment } from "@/models/comment";
import { CommentInput } from "@/components/comment/comment-input";
import "./comment-animations.css";
import { Suspense, useEffect, useState } from "react";
import { listComments } from "@/api/comment";
import { OrderBy } from "@/models/common";
import { CommentItem } from "./comment-item";
import { Separator } from "../ui/separator";
import { TargetType } from "@/models/types";
import { Skeleton } from "../ui/skeleton";
interface CommentAreaProps {
targetType: TargetType;
targetId: number;
}
export default function CommentSection(props: CommentAreaProps) {
const { targetType, targetId } = props;
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState<string>("");
useEffect(() => {
listComments({
targetType,
targetId,
depth: 0,
orderBy: OrderBy.CreatedAt,
desc: true,
page: 1,
size: 10
})
.then(response => {
setComments(response.data);
})
}, [targetType, targetId]);
const onCommentsChange = () => {
// 重新加载评论列表
listComments({
targetType,
targetId,
depth: 0,
orderBy: OrderBy.CreatedAt,
desc: true,
page: 1,
size: 10
})
.then(response => {
setComments(response.data);
})
}
// TODO: 支持分页加载更多评论
return (
<div>
<Separator className="my-16" />
<div className="font-bold text-2xl"></div>
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentsChange} />
<div className="mt-4">
<Suspense fallback={<CommentLoading />}>
{comments.map((comment, idx) => (
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
<Separator className="my-2" />
<CommentItem comment={comment} parentComment={null} onCommentDelete={onCommentsChange} />
</div>
))}
</Suspense>
</div>
</div>
);
}
function CommentLoading() {
return (
<div className="space-y-6 py-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex gap-3 fade-in-up" style={{ animationDelay: `${i * 80}ms` }}>
<Skeleton className="w-10 h-10 rounded-full fade-in" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/4 fade-in" />
<Skeleton className="h-4 w-3/4 fade-in" />
<Skeleton className="h-4 w-2/3 fade-in" />
</div>
</div>
))}
</div>
);
}