180 lines
5.8 KiB
TypeScript
180 lines
5.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
buildCommunityAvatarText,
|
||
formatCommunityTime,
|
||
maskCommunityUserId,
|
||
type CommunityComment,
|
||
} from "./CommunityMemoCard";
|
||
import { renderCommunityMarkdown } from "./renderMarkdown";
|
||
import { emitCommunityChanged } from "./communityEvents";
|
||
import styles from "./community.module.css";
|
||
|
||
type CommunityCommentThreadProps = {
|
||
memoId: string;
|
||
userId: string;
|
||
memoOwnerId: string;
|
||
comments?: CommunityComment[];
|
||
onCommentsChanged?: (comments: CommunityComment[]) => void;
|
||
};
|
||
|
||
export function CommunityCommentThread({
|
||
memoId,
|
||
userId,
|
||
memoOwnerId,
|
||
comments = [],
|
||
onCommentsChanged,
|
||
}: CommunityCommentThreadProps) {
|
||
const [items, setItems] = useState<CommunityComment[]>(comments);
|
||
const [content, setContent] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setItems(comments);
|
||
}, [comments]);
|
||
|
||
const syncComments = (nextItems: CommunityComment[]) => {
|
||
setItems(nextItems);
|
||
onCommentsChanged?.(nextItems);
|
||
};
|
||
|
||
const canSubmit = useMemo(() => Boolean(content.trim()) && !submitting, [content, submitting]);
|
||
|
||
const handleSubmit = async () => {
|
||
const trimmed = content.trim();
|
||
if (!trimmed || submitting) {
|
||
return;
|
||
}
|
||
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
const response = await fetch(`/api/community/memos/${memoId}/comments`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ user_id: userId, content: trimmed }),
|
||
});
|
||
const data = await response.json().catch(() => null);
|
||
|
||
if (!response.ok || !data?.comment) {
|
||
setError(data?.error || "回复发布失败,请稍后重试。");
|
||
return;
|
||
}
|
||
|
||
const nextItems = [...items, data.comment as CommunityComment];
|
||
syncComments(nextItems);
|
||
setContent("");
|
||
emitCommunityChanged();
|
||
} catch {
|
||
setError("网络异常,请稍后重试。");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (commentId: string) => {
|
||
if (!window.confirm("确定删除这条评论吗?")) {
|
||
return;
|
||
}
|
||
|
||
setDeletingId(commentId);
|
||
setError(null);
|
||
try {
|
||
const response = await fetch(
|
||
`/api/community/comments/${commentId}?user_id=${encodeURIComponent(userId)}`,
|
||
{ method: "DELETE" }
|
||
);
|
||
const data = await response.json().catch(() => null);
|
||
|
||
if (!response.ok) {
|
||
setError(data?.error || "评论删除失败,请稍后重试。");
|
||
return;
|
||
}
|
||
|
||
const nextItems = items.filter((item) => item.id !== commentId);
|
||
syncComments(nextItems);
|
||
emitCommunityChanged();
|
||
} catch {
|
||
setError("网络异常,请稍后重试。");
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className={styles.commentPanel}>
|
||
<span className={styles.commentEyebrow}>Discussion</span>
|
||
<h2 className={styles.commentTitle}>评论区</h2>
|
||
<p className={styles.commentHint}>把补充说明、追问和实践反馈继续沉淀在这里。</p>
|
||
|
||
<div className={styles.commentForm}>
|
||
<textarea
|
||
className={styles.commentTextarea}
|
||
value={content}
|
||
onChange={(event) => setContent(event.target.value)}
|
||
placeholder="写下你的补充、问题或结论..."
|
||
maxLength={3000}
|
||
disabled={submitting}
|
||
/>
|
||
<div className={styles.commentFormFooter}>
|
||
<span className={styles.commentCount}>{items.length} 条评论</span>
|
||
<button
|
||
type="button"
|
||
className={styles.commentSubmit}
|
||
onClick={() => void handleSubmit()}
|
||
disabled={!canSubmit}
|
||
>
|
||
{submitting ? "发送中..." : "发送评论"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error ? <p className={styles.commentStatus}>{error}</p> : null}
|
||
|
||
{items.length ? (
|
||
<div className={styles.commentList}>
|
||
{items.map((comment) => {
|
||
const canDelete = comment.user_id === userId || memoOwnerId === userId;
|
||
return (
|
||
<article key={comment.id} className={styles.commentItem}>
|
||
<div className={styles.commentHeader}>
|
||
<div className={styles.memoIdentity}>
|
||
<div className={styles.memoAvatar}>{buildCommunityAvatarText(comment.user_id)}</div>
|
||
<div className={styles.commentMeta}>
|
||
<span className={styles.commentAuthor}>{maskCommunityUserId(comment.user_id)}</span>
|
||
<span className={styles.commentTime}>
|
||
{formatCommunityTime(comment.created || comment.updated)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{canDelete ? (
|
||
<div className={styles.commentActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.commentDeleteBtn}
|
||
onClick={() => void handleDelete(comment.id)}
|
||
disabled={deletingId === comment.id}
|
||
>
|
||
{deletingId === comment.id ? "删除中..." : "删除"}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div
|
||
className={styles.commentContent}
|
||
dangerouslySetInnerHTML={{ __html: renderCommunityMarkdown(comment.content) }}
|
||
/>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className={styles.commentEmpty}>还没有评论,先留下第一条反馈。</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|