Files
juwan-frontend/components/post-comments.tsx
T
2026-04-25 21:41:01 +08:00

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>
)
}