mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-27 03:26:29 +00:00
Refactor comment components and update OIDC configuration
- Updated OIDC configuration to include additional fields in the UpdateOidcConfig method. - Enhanced CommentService to include IsPrivate field in the comment DTO. - Refactored comment components: renamed neo-comment to comment, and moved related files. - Implemented new CommentInput and CommentItem components for better structure and readability. - Removed obsolete files related to the old comment system. - Added CSS animations for comment components to improve user experience.
This commit is contained in:
80
web/src/components/comment/comment-input.tsx
Normal file
80
web/src/components/comment/comment-input.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useToLogin, useToUserProfile } from "@/hooks/use-route";
|
||||
import { User } from "@/models/user";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import NeedLogin from "@/components/common/need-login";
|
||||
import { toast } from "sonner";
|
||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
|
||||
export function CommentInput(
|
||||
{
|
||||
user,
|
||||
onCommentSubmitted,
|
||||
initContent = "",
|
||||
initIsPrivate = false,
|
||||
isUpdate = false
|
||||
}: {
|
||||
user: User | null,
|
||||
onCommentSubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void,
|
||||
initContent?: string,
|
||||
initIsPrivate?: boolean,
|
||||
isUpdate?: boolean,
|
||||
}
|
||||
) {
|
||||
const t = useTranslations('Comment')
|
||||
const handleToLogin = useToLogin()
|
||||
const toUserProfile = useToUserProfile();
|
||||
|
||||
const [isPrivate, setIsPrivate] = useState(initIsPrivate);
|
||||
const [commentContent, setCommentContent] = useState(initContent);
|
||||
|
||||
const handleCommentSubmit = async () => {
|
||||
if (!user) {
|
||||
toast.error(<NeedLogin>{t("login_required")}</NeedLogin>);
|
||||
return;
|
||||
}
|
||||
if (!commentContent.trim()) {
|
||||
toast.error(t("content_required"));
|
||||
return;
|
||||
}
|
||||
onCommentSubmitted({ commentContent, isPrivate });
|
||||
setCommentContent("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in-up">
|
||||
<div className="flex py-4 fade-in">
|
||||
<div onClick={user ? () => toUserProfile(user.username) : handleToLogin} className="flex-shrink-0 w-10 h-10 fade-in">
|
||||
{user ? getGravatarByUser(user) : null}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<Textarea
|
||||
placeholder={user?t("placeholder"):t("login_required")}
|
||||
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 space-x-4 items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={isPrivate}
|
||||
onCheckedChange={checked => setIsPrivate(checked === true)}
|
||||
/>
|
||||
<Label>{t("private")}</Label>
|
||||
</div>
|
||||
|
||||
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
|
||||
{isUpdate ? t("update") : t("submit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
226
web/src/components/comment/comment-item.tsx
Normal file
226
web/src/components/comment/comment-item.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { 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 toUserProfile = useToUserProfile();
|
||||
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 = () => {
|
||||
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={() => toUserProfile(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 >
|
||||
)
|
||||
}
|
125
web/src/components/comment/index.tsx
Normal file
125
web/src/components/comment/index.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { User } from "@/models/user";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Comment } from "@/models/comment";
|
||||
import { createComment, deleteComment, listComments } from "@/api/comment";
|
||||
import { TargetType } from "@/models/types";
|
||||
import { OrderBy } from "@/models/common";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getLoginUser } from "@/api/user";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { CommentItem } from "./comment-item";
|
||||
import config from "@/config";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
|
||||
|
||||
export function CommentSection(
|
||||
{
|
||||
targetType,
|
||||
targetId
|
||||
}: {
|
||||
targetType: TargetType,
|
||||
targetId: number
|
||||
}
|
||||
) {
|
||||
const t = useTranslations('Comment')
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [refreshCommentsKey, setRefreshCommentsKey] = useState(0);
|
||||
|
||||
// 获取当前登录用户
|
||||
useEffect(() => {
|
||||
getLoginUser()
|
||||
.then(response => {
|
||||
setCurrentUser(response.data);
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 加载0/顶层评论
|
||||
useEffect(() => {
|
||||
listComments({
|
||||
targetType,
|
||||
targetId,
|
||||
depth: 0,
|
||||
orderBy: OrderBy.CreatedAt,
|
||||
desc: true,
|
||||
page: 1,
|
||||
size: config.commentsPerPage,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
setComments(response.data);
|
||||
});
|
||||
}, [refreshCommentsKey])
|
||||
|
||||
const onCommentSubmitted = ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
|
||||
createComment({
|
||||
targetType,
|
||||
targetId,
|
||||
content: commentContent,
|
||||
replyId: null,
|
||||
isPrivate,
|
||||
}).then(() => {
|
||||
toast.success(t("comment_success"));
|
||||
setRefreshCommentsKey(k => k + 1);
|
||||
})
|
||||
}
|
||||
|
||||
const onCommentDelete = ({ commentId }: { commentId: number }) => {
|
||||
deleteComment({ id: commentId }).then(() => {
|
||||
toast.success(t("delete_success"));
|
||||
setRefreshCommentsKey(k => k + 1);
|
||||
}).catch(error => {
|
||||
toast.error(t("delete_failed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Separator className="my-16" />
|
||||
<div className="font-bold text-2xl">{t("comment")}</div>
|
||||
<CommentInput
|
||||
user={currentUser}
|
||||
onCommentSubmitted={onCommentSubmitted}
|
||||
/>
|
||||
<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
|
||||
user={currentUser}
|
||||
comment={comment}
|
||||
parentComment={null}
|
||||
onCommentDelete={onCommentDelete}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
58
web/src/components/comment/style.css
Normal file
58
web/src/components/comment/style.css
Normal file
@ -0,0 +1,58 @@
|
||||
/* 淡入上移 */
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 淡入 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 删除确认动画:缩放+淡入+颜色渐变 */
|
||||
.confirm-delete-anim {
|
||||
animation: fadeInScale 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
transition: color 0.25s, background 0.25s;
|
||||
}
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
color: #fff;
|
||||
background: #f87171;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
color: #fff;
|
||||
background: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮颜色、宽度、文本变化动画 */
|
||||
.animated-btn {
|
||||
transition:
|
||||
background 0.25s cubic-bezier(0.4,0,0.2,1),
|
||||
color 0.25s cubic-bezier(0.4,0,0.2,1),
|
||||
width 0.25s cubic-bezier(0.4,0,0.2,1),
|
||||
min-width 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
will-change: background, color, width, min-width;
|
||||
}
|
||||
|
||||
/* 输入框高度变化动画 */
|
||||
.animated-textarea {
|
||||
transition: min-height 0.25s cubic-bezier(0.4,0,0.2,1), height 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
Reference in New Issue
Block a user