170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { ThumbsUp, MessageSquare, ChevronDown, ChevronUp, Star } from "lucide-react";
|
|
import MemberBadge from "@/components/common/MemberBadge";
|
|
import { UserLevel } from "@prisma/client";
|
|
|
|
interface User {
|
|
id: number;
|
|
name: string;
|
|
avatarUrl?: string;
|
|
level: UserLevel;
|
|
points: number;
|
|
}
|
|
|
|
interface Reply {
|
|
id: number;
|
|
content: string;
|
|
likeCount: number;
|
|
createdAt: string;
|
|
liked: boolean;
|
|
user: User;
|
|
}
|
|
|
|
interface CommentData {
|
|
id: number;
|
|
content: string;
|
|
rating?: number;
|
|
likeCount: number;
|
|
isPinned: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
replyCount: number;
|
|
liked: boolean;
|
|
user: User;
|
|
replies?: Reply[];
|
|
}
|
|
|
|
interface CommentCardProps {
|
|
comment: CommentData;
|
|
targetType: "tool" | "skill";
|
|
targetSlug: string;
|
|
onReply: (parentId: number, replyToName: string) => void;
|
|
onLike: (commentId: number) => void;
|
|
}
|
|
|
|
function getTimeAgo(date: Date): string {
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const seconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
const months = Math.floor(days / 30);
|
|
|
|
if (seconds < 60) return "刚刚";
|
|
if (minutes < 60) return `${minutes}分钟前`;
|
|
if (hours < 24) return `${hours}小时前`;
|
|
if (days < 30) return `${days}天前`;
|
|
if (months < 12) return `${months}个月前`;
|
|
return date.toLocaleDateString("zh-CN");
|
|
}
|
|
|
|
export default function CommentCard({ comment, targetType, targetSlug, onReply, onLike }: CommentCardProps) {
|
|
const { data: session } = useSession();
|
|
const [showReplies, setShowReplies] = useState(false);
|
|
const [likeAnimating, setLikeAnimating] = useState(false);
|
|
|
|
const handleLike = () => {
|
|
setLikeAnimating(true);
|
|
onLike(comment.id);
|
|
setTimeout(() => setLikeAnimating(false), 300);
|
|
};
|
|
|
|
const initial = comment.user.name.charAt(0).toUpperCase();
|
|
|
|
return (
|
|
<div className={`rounded-xl border transition-all ${comment.isPinned ? "border-primary/30 bg-primary/[0.03]" : "border-border/40 bg-white hover:border-border/60"} shadow-[0_1px_2px_rgba(0,0,0,0.04)]`}>
|
|
{comment.isPinned && (
|
|
<div className="px-4 pt-2">
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M10 2a1 1 0 011 1v1.323l3.954 1.582 1.699-3.189a1 1 0 011.827 1.035L17.1 5.5H19a1 1 0 010 2h-2.095l-1.6 3.2a1 1 0 01-1.21.447L10 9.5V17a1 1 0 11-2 0v-7.5L3.805 7.5a1 1 0 01-1.21-.447L.995 3.85a1 1 0 011.827-1.035L4.427 5.5 8.364 3.924A1 1 0 019 3.09V3a1 1 0 011-1z" />
|
|
</svg>
|
|
置顶
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
{comment.user.avatarUrl ? (
|
|
<img src={comment.user.avatarUrl} alt="" className="h-8 w-8 rounded-full border border-border/40 object-cover" />
|
|
) : (
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
|
{initial}
|
|
</div>
|
|
)}
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-medium">{comment.user.name}</span>
|
|
<MemberBadge level={comment.user.level} points={comment.user.points} size="sm" showPoints={false} />
|
|
{comment.rating && (
|
|
<div className="flex items-center gap-0.5">
|
|
{[1, 2, 3, 4, 5].map((s) => (
|
|
<Star key={s} className={`w-3 h-3 ${s <= comment.rating! ? "fill-amber-400 text-amber-400" : "text-muted-foreground/20"}`} />
|
|
))}
|
|
</div>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground/60">{getTimeAgo(new Date(comment.createdAt))}</span>
|
|
</div>
|
|
|
|
<p className="mt-2 text-sm leading-relaxed text-foreground/85 whitespace-pre-wrap">{comment.content}</p>
|
|
|
|
<div className="mt-3 flex items-center gap-4">
|
|
<button
|
|
onClick={handleLike}
|
|
className={`flex items-center gap-1 text-xs transition-colors ${comment.liked ? "text-primary" : "text-muted-foreground/70 hover:text-foreground"}`}
|
|
>
|
|
<ThumbsUp className={`w-3.5 h-3.5 transition-transform ${likeAnimating ? "scale-125" : ""}`} />
|
|
<span>{comment.likeCount || "点赞"}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => onReply(comment.id, comment.user.name)}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
|
|
>
|
|
<MessageSquare className="w-3.5 h-3.5" />
|
|
<span>回复</span>
|
|
</button>
|
|
</div>
|
|
|
|
{comment.replies && comment.replies.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<button
|
|
onClick={() => setShowReplies(!showReplies)}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground/70 hover:text-foreground transition-colors"
|
|
>
|
|
{showReplies ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
|
<span>{comment.replyCount > (comment.replies?.length || 0) ? `共${comment.replyCount}条回复,已加载${comment.replies?.length}条` : `共${comment.replies.length}条回复`}</span>
|
|
</button>
|
|
|
|
{showReplies && comment.replies.map((reply) => (
|
|
<div key={reply.id} className="flex items-start gap-2 pl-3 border-l-2 border-border/30 py-2">
|
|
{reply.user.avatarUrl ? (
|
|
<img src={reply.user.avatarUrl} alt="" className="h-6 w-6 rounded-full border border-border/30 object-cover" />
|
|
) : (
|
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted/50 text-[10px] font-medium text-muted-foreground">
|
|
{reply.user.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-medium">{reply.user.name}</span>
|
|
<span className="text-[10px] text-muted-foreground/60">{getTimeAgo(new Date(reply.createdAt))}</span>
|
|
</div>
|
|
<p className="mt-0.5 text-xs text-foreground/80 whitespace-pre-wrap">{reply.content}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|