192 lines
6.5 KiB
TypeScript
192 lines
6.5 KiB
TypeScript
"use client"
|
||
|
||
import { addComment, listCommentsByPost, toggleCommentLike } from "@/lib/api/comments"
|
||
import { toApiError } from "@/lib/errors"
|
||
import { notifyInfo } from "@/lib/toast"
|
||
import type { Comment } from "@/lib/types"
|
||
import { useRequireAuth } from "@/lib/use-require-auth"
|
||
import { Heart } from "lucide-react"
|
||
import { useCallback, useEffect, useState } from "react"
|
||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
|
||
import { Button } from "./ui/button"
|
||
import { Textarea } from "./ui/textarea"
|
||
|
||
interface PostCommentsProps {
|
||
postId: string
|
||
}
|
||
|
||
export function PostComments({ postId }: PostCommentsProps) {
|
||
const [comments, setComments] = useState<Comment[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [content, setContent] = useState("")
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [pendingLike, setPendingLike] = useState<Record<string, boolean>>({})
|
||
const { requireAuth } = useRequireAuth()
|
||
|
||
const refresh = useCallback(
|
||
async (showLoading = true) => {
|
||
if (showLoading) {
|
||
setLoading(true)
|
||
setLoadError(null)
|
||
}
|
||
try {
|
||
const items = await listCommentsByPost(postId)
|
||
setComments(items)
|
||
} catch (err: unknown) {
|
||
setLoadError(toApiError(err).msg)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
},
|
||
[postId],
|
||
)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
void (async () => {
|
||
try {
|
||
const items = await listCommentsByPost(postId)
|
||
if (cancelled) return
|
||
setComments(items)
|
||
} catch (err: unknown) {
|
||
if (cancelled) return
|
||
setLoadError(toApiError(err).msg)
|
||
} finally {
|
||
if (!cancelled) setLoading(false)
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [postId])
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h2 className="font-semibold">
|
||
评论 ({loading && comments.length === 0 ? "..." : comments.length})
|
||
</h2>
|
||
|
||
<form
|
||
className="flex gap-3"
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
requireAuth(async () => {
|
||
if (submitting) return
|
||
|
||
const nextContent = content.trim()
|
||
if (!nextContent) return
|
||
|
||
setSubmitting(true)
|
||
const decision = await addComment(postId, nextContent)
|
||
if (!decision.ok) {
|
||
notifyInfo(decision.error.msg)
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
|
||
setContent("")
|
||
await refresh()
|
||
setSubmitting(false)
|
||
})
|
||
}}
|
||
>
|
||
<Textarea
|
||
placeholder="写下你的评论..."
|
||
className="flex-1"
|
||
rows={2}
|
||
value={content}
|
||
onChange={(event) => setContent(event.target.value)}
|
||
/>
|
||
<Button className="self-end" disabled={!content.trim() || submitting}>
|
||
发送
|
||
</Button>
|
||
</form>
|
||
|
||
{loading && comments.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground text-center py-8">加载中...</p>
|
||
) : loadError ? (
|
||
<p className="text-sm text-muted-foreground text-center py-8">加载失败:{loadError}</p>
|
||
) : comments.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground text-center py-8">还没有评论</p>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{comments.map((comment) => (
|
||
<div key={comment.id} className="flex gap-3">
|
||
<Avatar className="h-8 w-8">
|
||
<AvatarImage src={comment.author.avatar} />
|
||
<AvatarFallback>{comment.author.nickname[0]}</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-0.5">
|
||
<span className="text-sm font-medium">{comment.author.nickname}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{new Date(comment.createdAt).toLocaleString("zh-CN")}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm">{comment.content}</p>
|
||
<button
|
||
type="button"
|
||
disabled={pendingLike[comment.id]}
|
||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1 transition-colors"
|
||
onClick={() =>
|
||
requireAuth(() => {
|
||
if (pendingLike[comment.id]) return
|
||
|
||
const prevLiked = comment.liked
|
||
const prevCount = comment.likeCount
|
||
const nextLiked = !prevLiked
|
||
|
||
setComments((current) =>
|
||
current.map((item) => {
|
||
if (item.id !== comment.id) return item
|
||
return {
|
||
...item,
|
||
liked: nextLiked,
|
||
likeCount: Math.max(0, prevCount + (nextLiked ? 1 : -1)),
|
||
}
|
||
}),
|
||
)
|
||
setPendingLike((current) => ({ ...current, [comment.id]: true }))
|
||
|
||
toggleCommentLike(comment.id, prevLiked)
|
||
.then((decision) => {
|
||
if (!decision.ok) {
|
||
throw decision.error
|
||
}
|
||
})
|
||
.catch((err: unknown) => {
|
||
setComments((current) =>
|
||
current.map((item) => {
|
||
if (item.id !== comment.id) return item
|
||
return {
|
||
...item,
|
||
liked: prevLiked,
|
||
likeCount: prevCount,
|
||
}
|
||
}),
|
||
)
|
||
notifyInfo(toApiError(err).msg)
|
||
})
|
||
.finally(() => {
|
||
setPendingLike((current) => ({ ...current, [comment.id]: false }))
|
||
})
|
||
})
|
||
}
|
||
>
|
||
<Heart
|
||
className={`h-3 w-3 ${comment.liked ? "fill-red-500 text-red-500" : ""}`}
|
||
/>
|
||
{comment.likeCount}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|