212 lines
7.3 KiB
TypeScript
212 lines
7.3 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 { AlertCircle, Heart, MessageCircle } from "lucide-react"
|
|
import { useCallback, useEffect, useState } from "react"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
|
|
import { Button } from "./ui/button"
|
|
import { EmptyState } from "./ui/empty-state"
|
|
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">
|
|
<div className="flex items-center justify-between border-b border-border/60 pb-3">
|
|
<h2 className="font-semibold">评论</h2>
|
|
<span className="text-xs text-muted-foreground">
|
|
{loading && comments.length === 0 ? "加载中" : `${comments.length} 条`}
|
|
</span>
|
|
</div>
|
|
|
|
<form
|
|
className="space-y-3 rounded-xl border border-border/80 bg-card p-3 shadow-sm"
|
|
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="min-h-20 resize-none bg-transparent"
|
|
rows={2}
|
|
value={content}
|
|
onChange={(event) => setContent(event.target.value)}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" disabled={!content.trim() || submitting}>
|
|
发送
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
{loading && comments.length === 0 ? (
|
|
<EmptyState title="评论加载中" icon={MessageCircle} className="min-h-[180px]" />
|
|
) : loadError ? (
|
|
<EmptyState
|
|
title="评论加载失败"
|
|
description={loadError}
|
|
icon={AlertCircle}
|
|
className="min-h-[180px]"
|
|
/>
|
|
) : comments.length === 0 ? (
|
|
<EmptyState
|
|
title="还没有评论"
|
|
description="可以写下第一条评论。"
|
|
icon={MessageCircle}
|
|
className="min-h-[180px] border-dashed"
|
|
/>
|
|
) : (
|
|
<div className="overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm">
|
|
{comments.map((comment) => (
|
|
<div
|
|
key={comment.id}
|
|
className="flex gap-3 border-b border-border/60 p-4 last:border-0"
|
|
>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={comment.author.avatar} />
|
|
<AvatarFallback>{comment.author.nickname[0]}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-0.5 flex items-center gap-2">
|
|
<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 leading-relaxed">{comment.content}</p>
|
|
<button
|
|
type="button"
|
|
aria-label={comment.liked ? "取消点赞评论" : "点赞评论"}
|
|
disabled={pendingLike[comment.id]}
|
|
className="mt-2 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-60"
|
|
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-destructive text-destructive" : ""}`}
|
|
/>
|
|
{comment.likeCount}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|