mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
✨ feat: 重构评论功能,支持删除和点赞,更新国际化文本,优化组件结构
This commit is contained in:
13
.editorconfig
Normal file
13
.editorconfig
Normal file
@ -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
|
@ -1,15 +1,33 @@
|
|||||||
import axiosClient from './client'
|
import axiosClient from './client'
|
||||||
import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/comment'
|
import { UpdateCommentRequest, Comment } from '@/models/comment'
|
||||||
import type { PaginationParams } from '@/models/common'
|
import { PaginationParams } from '@/models/common'
|
||||||
import { OrderBy } from '@/models/common'
|
import { OrderBy } from '@/models/common'
|
||||||
import type { BaseResponse } from '@/models/resp'
|
import type { BaseResponse } from '@/models/resp'
|
||||||
import { TargetType } from '@/models/types'
|
import { TargetType } from '@/models/types'
|
||||||
|
|
||||||
|
|
||||||
export async function createComment(
|
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<BaseResponse<Comment>> {
|
): Promise<BaseResponse<Comment>> {
|
||||||
const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', data)
|
const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', {
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
content,
|
||||||
|
replyId,
|
||||||
|
isPrivate
|
||||||
|
})
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,28 +42,23 @@ export async function deleteComment(id: number): Promise<void> {
|
|||||||
await axiosClient.delete(`/comment/c/${id}`)
|
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
|
targetType: TargetType
|
||||||
targetId: number
|
targetId: number
|
||||||
depth?: number
|
depth: number
|
||||||
orderBy?: OrderBy
|
commentId: number
|
||||||
desc?: boolean
|
} & PaginationParams
|
||||||
page?: number
|
) {
|
||||||
size?: number
|
|
||||||
commentId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listComments(params: ListCommentsParams): Promise<BaseResponse<Comment[]>> {
|
|
||||||
const {
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
depth = 0,
|
|
||||||
orderBy = OrderBy.CreatedAt,
|
|
||||||
desc = true,
|
|
||||||
page = 1,
|
|
||||||
size = 10,
|
|
||||||
commentId = 0,
|
|
||||||
} = params
|
|
||||||
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
||||||
params: {
|
params: {
|
||||||
targetType,
|
targetType,
|
||||||
|
@ -4,7 +4,7 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square
|
|||||||
import { RenderMarkdown } from "@/components/common/markdown";
|
import { RenderMarkdown } from "@/components/common/markdown";
|
||||||
import { isMobileByUA } from "@/utils/server/device";
|
import { isMobileByUA } from "@/utils/server/device";
|
||||||
import { calculateReadingTime } from "@/utils/common/post";
|
import { calculateReadingTime } from "@/utils/common/post";
|
||||||
import CommentSection from "@/components/comment";
|
import {CommentSection} from "@/components/neo-comment";
|
||||||
import { TargetType } from '../../models/types';
|
import { TargetType } from '../../models/types';
|
||||||
|
|
||||||
function PostMeta({ post }: { post: Post }) {
|
function PostMeta({ post }: { post: Post }) {
|
||||||
|
@ -10,7 +10,7 @@ import { createComment } from "@/api/comment";
|
|||||||
import { CircleUser } from "lucide-react";
|
import { CircleUser } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { TargetType } from "@/models/types";
|
import { TargetType } from "@/models/types";
|
||||||
import { useToLogin } from "@/hooks/use-to-login";
|
import { useToLogin } from "@/hooks/use-route";
|
||||||
import NeedLogin from "../common/need-login";
|
import NeedLogin from "../common/need-login";
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +57,7 @@ export function CommentInput(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in-up">
|
<div className="fade-in-up">
|
||||||
<div className="flex py-4 fade-in">
|
<div className="flex py-4 fade-in">
|
||||||
|
@ -16,7 +16,7 @@ import type { User } from "@/models/user";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import "./comment-animations.css";
|
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 t = useTranslations("Comment")
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [liked, setLiked] = useState(comment.isLiked);
|
const [liked, setLiked] = useState(comment.isLiked);
|
||||||
@ -50,6 +50,7 @@ export function CommentItem({comment, parentComment}:{comment: Comment, parentCo
|
|||||||
deleteComment(id)
|
deleteComment(id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(t("delete_success"));
|
toast.success(t("delete_success"));
|
||||||
|
onCommentDelete();
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error(t("delete_failed") + ": " + error.message);
|
toast.error(t("delete_failed") + ": " + error.message);
|
||||||
@ -143,11 +144,28 @@ function RepliesList({ parentComment }: { parentComment: Comment }) {
|
|||||||
});
|
});
|
||||||
}, [parentComment])
|
}, [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 (
|
return (
|
||||||
<div className="mt-4 border-l border-slate-300 pl-4">
|
<div className="mt-4 border-l border-slate-300 pl-4">
|
||||||
{replies.map(reply => (
|
{replies.map(reply => (
|
||||||
<div key={reply.id} className="mb-4">
|
<div key={reply.id} className="mb-4">
|
||||||
<CommentItem comment={reply} parentComment={parentComment} />
|
<CommentItem comment={reply} parentComment={parentComment} onCommentDelete={onCommentDelete} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,7 +38,7 @@ export default function CommentSection(props: CommentAreaProps) {
|
|||||||
})
|
})
|
||||||
}, [targetType, targetId]);
|
}, [targetType, targetId]);
|
||||||
|
|
||||||
const onCommentSubmitted = () => {
|
const onCommentsChange = () => {
|
||||||
// 重新加载评论列表
|
// 重新加载评论列表
|
||||||
listComments({
|
listComments({
|
||||||
targetType,
|
targetType,
|
||||||
@ -54,17 +54,19 @@ export default function CommentSection(props: CommentAreaProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: 支持分页加载更多评论
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Separator className="my-16" />
|
<Separator className="my-16" />
|
||||||
<div className="font-bold text-2xl">评论</div>
|
<div className="font-bold text-2xl">评论</div>
|
||||||
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentSubmitted} />
|
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentsChange} />
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Suspense fallback={<CommentLoading />}>
|
<Suspense fallback={<CommentLoading />}>
|
||||||
{comments.map((comment, idx) => (
|
{comments.map((comment, idx) => (
|
||||||
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
|
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<CommentItem comment={comment} parentComment={null} />
|
<CommentItem comment={comment} parentComment={null} onCommentDelete={onCommentsChange} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useToLogin } from "@/hooks/use-to-login";
|
import { useToLogin } from "@/hooks/use-route";
|
||||||
|
|
||||||
export default function NeedLogin(
|
export default function NeedLogin(
|
||||||
{ children }: { children?: React.ReactNode }
|
{ children }: { children?: React.ReactNode }
|
||||||
|
279
web/src/components/neo-comment/index.tsx
Normal file
279
web/src/components/neo-comment/index.tsx
Normal file
@ -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<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: 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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<NeedLogin>{t("login_required")}</NeedLogin>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!commentContent.trim()) {
|
||||||
|
toast.error(t("content_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCommentSubmitted(commentContent);
|
||||||
|
setCommentContent("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fade-in-up">
|
||||||
|
<div className="flex py-4 fade-in">
|
||||||
|
<div onClick={user ? clickToUserprofile(user.username) : handleToLogin} className="flex-shrink-0 w-10 h-10 fade-in">
|
||||||
|
{user && getGravatarByUser(user)}
|
||||||
|
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{commentContent.trim() && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentItem(
|
||||||
|
{
|
||||||
|
user,
|
||||||
|
comment,
|
||||||
|
parentComment,
|
||||||
|
onCommentDelete,
|
||||||
|
}: {
|
||||||
|
user: User | null,
|
||||||
|
comment: Comment,
|
||||||
|
parentComment: Comment | null,
|
||||||
|
onCommentDelete: (commentId: number) => void,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const t = useTranslations("Comment")
|
||||||
|
const { confirming, onClick, onBlur } = useDoubleConfirm();
|
||||||
|
|
||||||
|
const [likeCount, setLikeCount] = useState(comment.likeCount);
|
||||||
|
const [liked, setLiked] = useState(comment.isLiked);
|
||||||
|
const [replyCount, setReplyCount] = useState(comment.replyCount);
|
||||||
|
const [showReplies, setShowReplies] = useState(false);
|
||||||
|
const [showReplyInput, setShowReplyInput] = 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 onReply = (replyContent: string) => {
|
||||||
|
setShowReplies(true);
|
||||||
|
setShowReplyInput(false);
|
||||||
|
setReplyCount(replyCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{
|
||||||
|
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 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
|
||||||
|
title={t("reply")}
|
||||||
|
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>
|
||||||
|
{/* 删除按钮 仅自己的评论可见 */}
|
||||||
|
{user?.id === comment.user.id && (
|
||||||
|
<button
|
||||||
|
title={t("delete")}
|
||||||
|
onClick={() => onClick(() => { comment.id && onCommentDelete(comment.id); })}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded bg-red-500 text-white hover:bg-red-600 transition animated-btn fade-in`}
|
||||||
|
>
|
||||||
|
<Trash className="w-3 h-3" />
|
||||||
|
{confirming && (
|
||||||
|
<span className="ml-1 confirm-delete-anim">{t("confirm_delete")}</span>
|
||||||
|
)}
|
||||||
|
</button>)}
|
||||||
|
|
||||||
|
{replyCount > 0 &&
|
||||||
|
<button onClick={() => setShowReplies(!showReplies)} className="fade-in-up">
|
||||||
|
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{showReplyInput && <CommentInput user={user} onCommentSubmitted={onReply} />}
|
||||||
|
</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/neo-comment/style.css
Normal file
58
web/src/components/neo-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);
|
||||||
|
}
|
25
web/src/hooks/use-double-confirm.tsx
Normal file
25
web/src/hooks/use-double-confirm.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useDoubleConfirm(timeout = 2000) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const timer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const onClick = (callback: () => void) => {
|
||||||
|
if (confirming) {
|
||||||
|
setConfirming(false);
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
setConfirming(true);
|
||||||
|
timer.current = setTimeout(() => setConfirming(false), timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 可选:失焦时自动取消
|
||||||
|
const onBlur = () => {
|
||||||
|
setConfirming(false);
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { confirming, onClick, onBlur };
|
||||||
|
}
|
27
web/src/hooks/use-route.ts
Normal file
27
web/src/hooks/use-route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useRouter, usePathname } from "next/navigation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于跳转到登录页并自动带上 redirect_back 参数
|
||||||
|
* 用法:const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
|
||||||
|
*/
|
||||||
|
export function useToLogin() {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
return () => {
|
||||||
|
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clickToUserprofile(username: string) {
|
||||||
|
const router = useRouter()
|
||||||
|
return () => {
|
||||||
|
router.push(`/user/${username}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clickToPost(postId: number) {
|
||||||
|
const router = useRouter()
|
||||||
|
return () => {
|
||||||
|
router.push(`/p/${postId}`)
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +0,0 @@
|
|||||||
import { useRouter, usePathname } from "next/navigation"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用于跳转到登录页并自动带上 redirect_back 参数
|
|
||||||
* 用法:const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
|
|
||||||
*/
|
|
||||||
export function useToLogin() {
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
return () => {
|
|
||||||
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -3,17 +3,21 @@
|
|||||||
"title": "Hello world!"
|
"title": "Hello world!"
|
||||||
},
|
},
|
||||||
"Comment": {
|
"Comment": {
|
||||||
|
"comment": "评论",
|
||||||
"placeholder": "写你的评论...",
|
"placeholder": "写你的评论...",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"login_required": "请先登录后再评论。",
|
"login_required": "请先登录后再评论。",
|
||||||
"content_required": "评论内容不能为空。",
|
"content_required": "评论内容不能为空。",
|
||||||
"comment_success": "评论提交成功!",
|
"comment_success": "评论成功!",
|
||||||
"comment_failed": "评论提交失败",
|
"comment_failed": "评论失败",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"delete_success": "评论删除成功",
|
"delete_success": "评论已经删除",
|
||||||
"confirm_delete": "确定删除?",
|
"delete_failed": "删除评论失败",
|
||||||
"like_success": "点赞评论成功",
|
"confirm_delete": "确定吗?",
|
||||||
"unlike_success": "取消点赞成功",
|
"like": "点赞",
|
||||||
|
"unlike": "取消点赞",
|
||||||
|
"like_success": "点赞成功",
|
||||||
|
"unlike_success": "已取消点赞",
|
||||||
"like_failed": "点赞失败",
|
"like_failed": "点赞失败",
|
||||||
"reply": "回复",
|
"reply": "回复",
|
||||||
"expand_replies": "展开 {count} 条回复",
|
"expand_replies": "展开 {count} 条回复",
|
||||||
|
@ -2,31 +2,23 @@ import { TargetType } from "./types"
|
|||||||
import type { User } from "./user"
|
import type { User } from "./user"
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number
|
id: number
|
||||||
targetType: TargetType
|
targetType: TargetType
|
||||||
targetId: number
|
targetId: number
|
||||||
content: string
|
content: string
|
||||||
replyId: number
|
replyId: number
|
||||||
depth: number
|
depth: number
|
||||||
isPrivate: boolean
|
isPrivate: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
user: User
|
user: User
|
||||||
replyCount: number
|
replyCount: number
|
||||||
likeCount: number
|
likeCount: number
|
||||||
isLiked: boolean
|
isLiked: boolean
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateCommentRequest {
|
|
||||||
targetType: TargetType
|
|
||||||
targetId: number
|
|
||||||
content: string
|
|
||||||
replyId: number | null// 可选字段,默认为 null
|
|
||||||
isPrivate: boolean// 可选字段,默认为 false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCommentRequest {
|
export interface UpdateCommentRequest {
|
||||||
id: number
|
id: number
|
||||||
content: string
|
content: string
|
||||||
isPrivate?: boolean // 可选字段,默认为 false
|
isPrivate?: boolean // 可选字段,默认为 false
|
||||||
}
|
}
|
@ -1,15 +1,15 @@
|
|||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
orderBy: OrderBy
|
orderBy: OrderBy
|
||||||
desc: boolean
|
desc: boolean
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum OrderBy {
|
export enum OrderBy {
|
||||||
CreatedAt = 'created_at',
|
CreatedAt = 'created_at',
|
||||||
UpdatedAt = 'updated_at',
|
UpdatedAt = 'updated_at',
|
||||||
Heat = 'heat',
|
Heat = 'heat',
|
||||||
CommentCount = 'comment_count',
|
CommentCount = 'comment_count',
|
||||||
LikeCount = 'like_count',
|
LikeCount = 'like_count',
|
||||||
ViewCount = 'view_count',
|
ViewCount = 'view_count',
|
||||||
}
|
}
|
Reference in New Issue
Block a user