mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: implement advanced comment features including reply and like functionality
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s
- Added support for nested comments with reply functionality. - Implemented like/unlike feature for comments and posts. - Enhanced comment DTO to include reply count, like count, and like status. - Updated comment and like services to handle new functionalities. - Created new API endpoints for toggling likes and listing comments. - Improved UI components for comments to support replies and likes with animations. - Added localization for new comment-related messages. - Introduced a TODO list for future enhancements in the comment module.
This commit is contained in:
@ -32,6 +32,7 @@ export interface ListCommentsParams {
|
||||
desc?: boolean
|
||||
page?: number
|
||||
size?: number
|
||||
commentId?: number
|
||||
}
|
||||
|
||||
export async function listComments(params: ListCommentsParams): Promise<BaseResponse<Comment[]>> {
|
||||
@ -43,6 +44,7 @@ export async function listComments(params: ListCommentsParams): Promise<BaseResp
|
||||
desc = true,
|
||||
page = 1,
|
||||
size = 10,
|
||||
commentId = 0,
|
||||
} = params
|
||||
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
||||
params: {
|
||||
@ -52,7 +54,8 @@ export async function listComments(params: ListCommentsParams): Promise<BaseResp
|
||||
orderBy,
|
||||
desc,
|
||||
page,
|
||||
size
|
||||
size,
|
||||
commentId,
|
||||
}
|
||||
})
|
||||
return res.data
|
||||
|
11
web/src/api/like.ts
Normal file
11
web/src/api/like.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import axiosClient from './client'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
import { TargetType } from '@/models/types'
|
||||
|
||||
|
||||
export async function toggleLike(
|
||||
{ targetType, targetId }: { targetType: TargetType, targetId: number },
|
||||
): Promise<BaseResponse<{ status: boolean }>> {
|
||||
const res = await axiosClient.put<BaseResponse<{ status: boolean }>>('/like/toggle', { targetType, targetId })
|
||||
return res.data
|
||||
}
|
@ -112,6 +112,10 @@
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
|
||||
:root {
|
||||
--animation-duration: 0.6s;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
47
web/src/components/comment/comment-animations.css
Normal file
47
web/src/components/comment/comment-animations.css
Normal file
@ -0,0 +1,47 @@
|
||||
/* 评论区原生动画:淡入、上移 */
|
||||
.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);
|
||||
}
|
||||
}
|
@ -13,9 +13,13 @@ import { TargetType } from "@/models/types";
|
||||
import { useToLogin } from "@/hooks/use-to-login";
|
||||
import NeedLogin from "../common/need-login";
|
||||
|
||||
|
||||
import "./comment-animations.css";
|
||||
|
||||
export function CommentInput(
|
||||
{ targetId, targetType, onCommentSubmitted }: { targetId: number, targetType: TargetType, onCommentSubmitted: () => void }
|
||||
{ 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);
|
||||
@ -41,6 +45,8 @@ export function CommentInput(
|
||||
targetType: targetType,
|
||||
targetId: targetId,
|
||||
content: commentContent,
|
||||
replyId: replyId,
|
||||
isPrivate: false,
|
||||
}).then(response => {
|
||||
setCommentContent("");
|
||||
toast.success(t("comment_success"));
|
||||
@ -52,25 +58,25 @@ export function CommentInput(
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="flex py-4">
|
||||
<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">
|
||||
<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" />}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
{/* Input Area */}
|
||||
<div className="flex-1 pl-2">
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<Textarea
|
||||
placeholder={t("placeholder")}
|
||||
className="w-full p-2 border border-gray-300 rounded-md"
|
||||
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">
|
||||
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
|
||||
<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>
|
||||
|
@ -1,42 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import type { Comment } from "@/models/comment";
|
||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
||||
import { useState, useEffect } from "react";
|
||||
import { get } from "http";
|
||||
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}:{comment: Comment, parentComment: Comment | null}) {
|
||||
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"));
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(t("delete_failed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
const onReplySubmitted = () => {
|
||||
setReplyCount(replyCount + 1);
|
||||
setShowReplyInput(false);
|
||||
setShowReplies(true);
|
||||
}
|
||||
|
||||
export function CommentItem(comment: Comment) {
|
||||
const [replies, setReplies] = useState<Comment[]>([]);
|
||||
const [loadingReplies, setLoadingReplies] = useState(false);
|
||||
return (
|
||||
<div className="flex">
|
||||
<div>
|
||||
<div className="flex fade-in-up">
|
||||
<div className="fade-in">
|
||||
{getGravatarByUser(comment.user)}
|
||||
</div>
|
||||
<div className="flex-1 pl-2">
|
||||
<div className="font-bold">{comment.user.nickname}</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">{comment.content}</p>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{new Date(comment.updatedAt).toLocaleString()}
|
||||
<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 ReplyItem({ reply }: { reply: Comment }) {
|
||||
// 一个评论的回复区域组件
|
||||
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])
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700 shadow-sm rounded-lg p-4 mb-2 ml-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
{reply.user.nickname}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">{reply.content}</p>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{new Date(reply.updatedAt).toLocaleString()}
|
||||
</div>
|
||||
<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} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@ -57,13 +58,13 @@ export default function CommentSection(props: CommentAreaProps) {
|
||||
<div>
|
||||
<Separator className="my-16" />
|
||||
<div className="font-bold text-2xl">评论</div>
|
||||
<CommentInput targetType={targetType} targetId={targetId} onCommentSubmitted={onCommentSubmitted} />
|
||||
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentSubmitted} />
|
||||
<div className="mt-4">
|
||||
<Suspense fallback={<CommentLoading />}>
|
||||
{comments.map(comment => (
|
||||
<div key={comment.id}>
|
||||
{comments.map((comment, idx) => (
|
||||
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
|
||||
<Separator className="my-2" />
|
||||
<CommentItem {...comment} />
|
||||
<CommentItem comment={comment} parentComment={null} />
|
||||
</div>
|
||||
))}
|
||||
</Suspense>
|
||||
@ -77,12 +78,12 @@ function CommentLoading() {
|
||||
return (
|
||||
<div className="space-y-6 py-8">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="w-10 h-10 rounded-full" />
|
||||
<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" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<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>
|
||||
))}
|
||||
|
@ -77,7 +77,8 @@ export function LoginForm({
|
||||
<LoginWithOidc
|
||||
key={uniqueKey}
|
||||
// 这个REDIRECT_BACK需要前端自己拼接,传给后端服务器,后端服务器拿来响应给前端另一个页面获取,然后改变路由
|
||||
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
|
||||
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
|
||||
// 貌似是因为oidc认证时是后端响应重定向的,所以前端只能把redirect_back传给后端,由后端再传回来;普通登录时,这个参数可以被前端直接拿到进行路由跳转
|
||||
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
|
||||
displayName={config.displayName}
|
||||
icon={config.icon}
|
||||
|
@ -3,12 +3,21 @@
|
||||
"title": "Hello world!"
|
||||
},
|
||||
"Comment": {
|
||||
"placeholder": "写下你的评论...",
|
||||
"placeholder": "写你的评论...",
|
||||
"submit": "提交",
|
||||
"login_required": "请先登录后再评论。",
|
||||
"content_required": "评论内容不能为空。",
|
||||
"comment_success": "评论提交成功!",
|
||||
"comment_failed": "评论提交失败"
|
||||
"comment_failed": "评论提交失败",
|
||||
"delete": "删除",
|
||||
"delete_success": "评论删除成功",
|
||||
"confirm_delete": "确定删除?",
|
||||
"like_success": "点赞评论成功",
|
||||
"unlike_success": "取消点赞成功",
|
||||
"like_failed": "点赞失败",
|
||||
"reply": "回复",
|
||||
"expand_replies": "展开 {count} 条回复",
|
||||
"collapse_replies": "收起回复"
|
||||
},
|
||||
"Login": {
|
||||
"welcome": "欢迎回来",
|
||||
|
@ -12,14 +12,17 @@ export interface Comment {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: User
|
||||
replyCount: number
|
||||
likeCount: number
|
||||
isLiked: boolean
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
targetType: TargetType
|
||||
targetId: number
|
||||
content: string
|
||||
replyId?: number // 可选字段,默认为 null
|
||||
isPrivate?: boolean // 可选字段,默认为 false
|
||||
replyId: number | null// 可选字段,默认为 null
|
||||
isPrivate: boolean// 可选字段,默认为 false
|
||||
}
|
||||
|
||||
export interface UpdateCommentRequest {
|
||||
|
0
web/src/models/like.tsx
Normal file
0
web/src/models/like.tsx
Normal file
@ -1,8 +1,10 @@
|
||||
|
||||
// 目标类型枚举
|
||||
export enum TargetType {
|
||||
Post = "post",
|
||||
Page = "page",
|
||||
Article = "article",
|
||||
Comment = "comment",
|
||||
Video = "video",
|
||||
Image = "image",
|
||||
Other = "other"
|
||||
|
Reference in New Issue
Block a user