diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6ad3b14 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 2 + +[*.ts] +indent_style = space +indent_size = 2 + +[*.tsx] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/web/src/api/comment.ts b/web/src/api/comment.ts index 91e78b9..a7a1b76 100644 --- a/web/src/api/comment.ts +++ b/web/src/api/comment.ts @@ -1,15 +1,33 @@ import axiosClient from './client' -import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/comment' -import type { PaginationParams } from '@/models/common' +import { UpdateCommentRequest, Comment } from '@/models/comment' +import { PaginationParams } from '@/models/common' import { OrderBy } from '@/models/common' import type { BaseResponse } from '@/models/resp' import { TargetType } from '@/models/types' export async function createComment( - data: CreateCommentRequest, + { + targetType, + targetId, + content, + replyId = null, + isPrivate = false + }: { + targetType: TargetType + targetId: number + content: string + replyId: number | null + isPrivate: boolean + } ): Promise> { - const res = await axiosClient.post>('/comment/c', data) + const res = await axiosClient.post>('/comment/c', { + targetType, + targetId, + content, + replyId, + isPrivate + }) return res.data } @@ -24,28 +42,23 @@ export async function deleteComment(id: number): Promise { await axiosClient.delete(`/comment/c/${id}`) } -export interface ListCommentsParams { + +export async function listComments({ + targetType, + targetId, + depth = 0, + commentId = 0, + orderBy = OrderBy.CreatedAt, + desc = true, + page = 1, + size = 10, +}: { targetType: TargetType targetId: number - depth?: number - orderBy?: OrderBy - desc?: boolean - page?: number - size?: number - commentId?: number -} - -export async function listComments(params: ListCommentsParams): Promise> { - const { - targetType, - targetId, - depth = 0, - orderBy = OrderBy.CreatedAt, - desc = true, - page = 1, - size = 10, - commentId = 0, - } = params + depth: number + commentId: number +} & PaginationParams +) { const res = await axiosClient.get>(`/comment/list`, { params: { targetType, diff --git a/web/src/components/blog-post/blog-post.tsx b/web/src/components/blog-post/blog-post.tsx index 05be701..f4e40ff 100644 --- a/web/src/components/blog-post/blog-post.tsx +++ b/web/src/components/blog-post/blog-post.tsx @@ -4,7 +4,7 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square import { RenderMarkdown } from "@/components/common/markdown"; import { isMobileByUA } from "@/utils/server/device"; import { calculateReadingTime } from "@/utils/common/post"; -import CommentSection from "@/components/comment"; +import {CommentSection} from "@/components/neo-comment"; import { TargetType } from '../../models/types'; function PostMeta({ post }: { post: Post }) { diff --git a/web/src/components/comment/comment-input.tsx b/web/src/components/comment/comment-input.tsx index fba7f13..3983abf 100644 --- a/web/src/components/comment/comment-input.tsx +++ b/web/src/components/comment/comment-input.tsx @@ -10,7 +10,7 @@ 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-to-login"; +import { useToLogin } from "@/hooks/use-route"; import NeedLogin from "../common/need-login"; @@ -57,6 +57,7 @@ export function CommentInput( ); }); }; + return (
diff --git a/web/src/components/comment/comment-item.tsx b/web/src/components/comment/comment-item.tsx index 4cf4264..c3e710d 100644 --- a/web/src/components/comment/comment-item.tsx +++ b/web/src/components/comment/comment-item.tsx @@ -16,7 +16,7 @@ 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}) { +export function CommentItem({comment, parentComment, onCommentDelete}:{comment: Comment, parentComment: Comment | null, onCommentDelete: () => void}) { const t = useTranslations("Comment") const [user, setUser] = useState(null); const [liked, setLiked] = useState(comment.isLiked); @@ -50,6 +50,7 @@ export function CommentItem({comment, parentComment}:{comment: Comment, parentCo deleteComment(id) .then(() => { toast.success(t("delete_success")); + onCommentDelete(); }) .catch(error => { toast.error(t("delete_failed") + ": " + error.message); @@ -143,11 +144,28 @@ function RepliesList({ parentComment }: { parentComment: Comment }) { }); }, [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 (
{replies.map(reply => (
- +
))}
diff --git a/web/src/components/comment/index.tsx b/web/src/components/comment/index.tsx index 9c2d1c1..88fd398 100644 --- a/web/src/components/comment/index.tsx +++ b/web/src/components/comment/index.tsx @@ -38,7 +38,7 @@ export default function CommentSection(props: CommentAreaProps) { }) }, [targetType, targetId]); - const onCommentSubmitted = () => { + const onCommentsChange = () => { // 重新加载评论列表 listComments({ targetType, @@ -54,17 +54,19 @@ export default function CommentSection(props: CommentAreaProps) { }) } + // TODO: 支持分页加载更多评论 + return (
评论
- +
}> {comments.map((comment, idx) => (
- +
))}
diff --git a/web/src/components/common/need-login.tsx b/web/src/components/common/need-login.tsx index 112dd65..1c7e9da 100644 --- a/web/src/components/common/need-login.tsx +++ b/web/src/components/common/need-login.tsx @@ -1,4 +1,4 @@ -import { useToLogin } from "@/hooks/use-to-login"; +import { useToLogin } from "@/hooks/use-route"; export default function NeedLogin( { children }: { children?: React.ReactNode } diff --git a/web/src/components/neo-comment/index.tsx b/web/src/components/neo-comment/index.tsx new file mode 100644 index 0000000..c6da747 --- /dev/null +++ b/web/src/components/neo-comment/index.tsx @@ -0,0 +1,279 @@ +"use client" + +import { clickToUserprofile, useToLogin } from "@/hooks/use-route"; +import { User } from "@/models/user"; +import { useTranslations } from "next-intl"; +import { Suspense, use, useEffect, useState } from "react"; +import NeedLogin from "../common/need-login"; +import { toast } from "sonner"; +import { getGravatarByUser } from "../common/gravatar"; +import { CircleUser, Reply, Trash } from "lucide-react"; +import { Textarea } from "../ui/textarea"; +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 "../ui/separator"; +import { getLoginUser } from "@/api/user"; +import { Skeleton } from "../ui/skeleton"; +import { toggleLike } from "@/api/like"; +import { useDoubleConfirm } from "@/hooks/use-double-confirm"; + +import "./style.css"; + +const DEFAULT_PAGE_SIZE = 20; + +export function CommentSection( + { + targetType, + targetId + }: { + targetType: TargetType, + targetId: number + } +) { + const t = useTranslations('Comment') + + const [currentUser, setCurrentUser] = useState(null); + const [comments, setComments] = useState([]); + 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: DEFAULT_PAGE_SIZE, + commentId: 0 + }).then(response => { + setComments(response.data); + }); + }, [refreshCommentsKey]) + + const onCommentSubmitted = (commentContent: string) => { + createComment({ + targetType, + targetId, + content: commentContent, + replyId: null, + isPrivate: false + }).then(() => { + toast.success(t("comment_success")); + setRefreshCommentsKey(k => k + 1); + }) + } + + const onCommentDelete = (commentId: number) => { + deleteComment(commentId).then(() => { + toast.success(t("delete_success")); + setRefreshCommentsKey(k => k + 1); + }).catch(error => { + toast.error(t("delete_failed") + ": " + error.message); + }); + } + + return ( +
+ +
{t("comment")}
+ +
+ }> + {comments.map((comment, idx) => ( +
+ + +
+ ))} +
+
+
+ ) +} + +export function CommentInput( + { user, onCommentSubmitted, }: { user: User | null, onCommentSubmitted: (commentContent: string) => void } +) { + const t = useTranslations('Comment') + const handleToLogin = useToLogin() + + const [commentContent, setCommentContent] = useState(""); + + const handleCommentSubmit = async () => { + if (!user) { + toast.error({t("login_required")}); + return; + } + if (!commentContent.trim()) { + toast.error(t("content_required")); + return; + } + onCommentSubmitted(commentContent); + setCommentContent(""); + }; + + return ( +
+
+
+ {user && getGravatarByUser(user)} + {!user && } +
+
+