Files
juwan-frontend/components/post-comments.tsx
T

192 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}