Files
jiang13-bbs/frontend/components/SnippetCommentSection.tsx

703 lines
22 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 { useState, useEffect, useCallback, useRef } from "react";
import { MessageSquare, Send } from "lucide-react";
import {
apiCreateSnippetComment,
apiUpdateSnippetComment,
apiDeleteSnippetComment,
apiSnippetCommentHistory,
apiMe,
type SnippetCommentItem,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import { formatRelative, formatDateTime } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import Link from "next/link";
import MarkdownEditor from "@/components/MarkdownEditor";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import EditHistoryModal, { type EditHistoryItem } from "@/components/EditHistoryModal";
import ConfirmDialog from "@/components/ConfirmDialog";
import { snippetCommentCountEvent } from "@/components/SnippetCommentCountChip";
const COMMENT_MAX = 2000; // 与后端 snippetCommentMaxLen 一致
type MoreItem = {
key: string;
label: string;
danger?: boolean;
disabled?: boolean;
onSelect: () => void;
};
/** 低频操作:编辑、历史、删除。Escape 关闭并回到触发按钮。 */
function CommentMoreMenu({ items }: { items: MoreItem[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setOpen(false);
btnRef.current?.focus();
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
const normal = items.filter((item) => !item.danger);
const danger = items.filter((item) => item.danger);
return (
<div className="cmt-more" ref={ref}>
<button
ref={btnRef}
type="button"
className="cmt-more-btn"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
更多
</button>
{open && (
<div role="menu" className="cmt-more-menu" aria-label="评论操作">
{normal.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
{normal.length > 0 && danger.length > 0 ? <div className="cmt-more-sep" role="separator" /> : null}
{danger.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
className="is-danger"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
function CommentNode({
item,
depth,
snippetId,
onUpdate,
onDelete,
onOpenHistory,
loggedIn,
}: {
item: SnippetCommentItem;
depth: number;
snippetId: number;
onUpdate: (id: number, content: string) => Promise<void>;
onDelete: (id: number) => Promise<void>;
onOpenHistory: (id: number, content: string) => void;
loggedIn: boolean;
}) {
const urlStyle = useUrlStyle();
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(item.content);
const [editUploading, setEditUploading] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const [replyContent, setReplyContent] = useState("");
const [replyUploading, setReplyUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
const [isStaff, setIsStaff] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(false);
useEffect(() => {
apiMe()
.then((res) => {
if (res.user) {
setCurrentUserId(res.user.id);
setIsStaff(["admin", "moderator"].includes(res.user.role));
}
})
.catch(() => {});
}, []);
const canEdit = currentUserId === item.user_id;
const canDelete = canEdit || isStaff;
const canViewHistory = loggedIn;
const userName = item.user ? item.user.nickname || item.user.username : "已注销";
const isFloor = depth === 0;
const handleEdit = async () => {
const next = editContent.trim();
if (!next || submitting) return;
setSubmitting(true);
try {
await onUpdate(item.id, next);
setEditing(false);
toast("已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
setDeleteTarget(false);
if (submitting) return;
setSubmitting(true);
try {
await onDelete(item.id);
toast("已删除", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setSubmitting(false);
}
};
const handleReply = async () => {
const next = replyContent.trim();
if (!next || submitting) return;
setSubmitting(true);
try {
await apiCreateSnippetComment(snippetId, next, item.id);
setReplyContent("");
setReplyOpen(false);
toast("已回复", "ok");
window.dispatchEvent(new CustomEvent("j13-snippet-comment-refresh"));
} catch (e) {
toast(e instanceof Error ? e.message : "回复失败");
} finally {
setSubmitting(false);
}
};
const moreItems: MoreItem[] = [];
if (canEdit && !editing) {
moreItems.push({
key: "edit",
label: "编辑",
disabled: submitting,
onSelect: () => setEditing(true),
});
}
if (canViewHistory && item.edited && !editing) {
moreItems.push({
key: "history",
label: "编辑历史",
onSelect: () => onOpenHistory(item.id, item.content),
});
}
if (canDelete && !editing) {
moreItems.push({
key: "delete",
label: "删除",
danger: true,
disabled: submitting,
onSelect: () => setDeleteTarget(true),
});
}
const editedMark = item.edited ? (
canViewHistory ? (
<button
type="button"
className="cmt-edited"
onClick={() => onOpenHistory(item.id, item.content)}
>
已编辑
</button>
) : (
<span className="cmt-edited" data-tip="此评论曾被编辑">
已编辑
</span>
)
) : null;
if (item.deleted) {
return (
<div
id={item.floor ? `comment-${item.floor}` : undefined}
data-comment-id={item.id}
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
>
<div className="flex gap-3">
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
<span className="cmt-name" style={{ color: "var(--ink)" }}>
{userName}
</span>
{item.floor && item.floor > 0 && (
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
#{item.floor}
</a>
)}
<span className="cmt-time">{formatRelative(item.created_at)}</span>
</div>
</div>
<div className="cmt-tombstone">
<p className="cmt-tombstone-title">该评论已删除</p>
</div>
<div className="cmt-actions">
<CommentMoreMenu items={moreItems} />
</div>
{item.replies && item.replies.length > 0 && (
<div className="cmt-children">
{item.replies.map((r) => (
<CommentNode
key={r.id}
item={r}
depth={depth + 1}
snippetId={snippetId}
onUpdate={onUpdate}
onDelete={onDelete}
onOpenHistory={onOpenHistory}
loggedIn={loggedIn}
/>
))}
</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget}
title="删除评论"
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
confirmLabel="删除"
danger
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(false)}
/>
</div>
);
}
return (
<div
id={item.floor ? `comment-${item.floor}` : undefined}
data-comment-id={item.id}
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
>
<div className="flex gap-3">
<Link
href={userPath(urlStyle, item.user_id)}
className="shrink-0"
aria-label={userName}
>
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
</Link>
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
<Link href={userPath(urlStyle, item.user_id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{userName}
</span>
</Link>
{item.floor && item.floor > 0 && (
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
#{item.floor}
</a>
)}
<time className="cmt-time" dateTime={item.created_at} data-tip={formatDateTime(item.created_at)}>
{formatRelative(item.created_at)}
</time>
{editedMark}
</div>
</div>
{editing ? (
<div className="cmt-md-editor mt-1">
<MarkdownEditor
density="comment"
value={editContent}
onChange={(v) => setEditContent(v.slice(0, COMMENT_MAX))}
maxChars={COMMENT_MAX}
disabled={submitting}
autoFocus
allowHide={false}
ariaLabel="编辑评论"
placeholder="编辑评论…"
shortcutName="保存"
onUploadingChange={setEditUploading}
onSubmitShortcut={() => {
void handleEdit();
}}
footer={
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={submitting}
onClick={() => {
setEditing(false);
setEditContent(item.content);
}}
>
取消
</button>
<button
type="button"
onClick={handleEdit}
disabled={submitting || editUploading || !editContent.trim()}
className="btn btn-primary cmt-submit"
>
{submitting ? "保存中…" : "保存修改"}
</button>
</>
}
/>
</div>
) : (
<MarkdownBodyClient content={item.content} className="prose-content prose-comment" />
)}
<div className="cmt-actions">
{!editing && loggedIn && (
<button
type="button"
onClick={() => setReplyOpen(!replyOpen)}
className={`cmt-reply-btn${replyOpen ? " !text-[var(--accent)]" : ""}`}
aria-label={`回复 ${userName}`}
>
回复
</button>
)}
<CommentMoreMenu items={moreItems} />
</div>
{replyOpen && !editing && (
<form
onSubmit={(e) => {
e.preventDefault();
void handleReply();
}}
className="cmt-reply-form"
>
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
<span>
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{userName}</span>
</span>
<button type="button" className="cmt-reply-btn" onClick={() => setReplyOpen(false)}>
取消
</button>
</div>
<div className="cmt-md-editor">
<MarkdownEditor
density="comment"
value={replyContent}
onChange={(v) => setReplyContent(v.slice(0, COMMENT_MAX))}
maxChars={COMMENT_MAX}
disabled={submitting}
autoFocus
allowHide={false}
ariaLabel={`回复 ${userName}`}
placeholder="写下你的回复…"
shortcutName="回复"
onUploadingChange={setReplyUploading}
onSubmitShortcut={() => {
void handleReply();
}}
footer={
<button
type="submit"
disabled={submitting || replyUploading || !replyContent.trim()}
aria-busy={submitting}
className="btn btn-primary cmt-submit"
>
{submitting ? "回复中…" : "回复"}
</button>
}
/>
</div>
</form>
)}
{item.replies && item.replies.length > 0 && (
<div className="cmt-children">
{item.replies.map((r) => (
<CommentNode
key={r.id}
item={r}
depth={depth + 1}
snippetId={snippetId}
onUpdate={onUpdate}
onDelete={onDelete}
onOpenHistory={onOpenHistory}
loggedIn={loggedIn}
/>
))}
</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget}
title="删除评论"
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
confirmLabel="删除"
danger
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(false)}
/>
</div>
);
}
export default function SnippetCommentSection({
snippetId,
initialItems,
initialTotal,
urlStyle: _urlStyle,
}: {
snippetId: number;
initialItems: SnippetCommentItem[];
initialTotal: number;
urlStyle: string;
}) {
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(initialTotal);
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [loggedIn, setLoggedIn] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const [historyCommentContent, setHistoryCommentContent] = useState("");
useEffect(() => {
window.dispatchEvent(new CustomEvent(snippetCommentCountEvent(snippetId), { detail: total }));
}, [total, snippetId]);
useEffect(() => {
apiMe()
.then((res) => setLoggedIn(!!res.user))
.catch(() => {});
}, []);
const loadComments = useCallback(
async (p: number) => {
setLoading(true);
try {
const { fetchSnippetComments } = await import("@/lib/api");
const res = await fetchSnippetComments(snippetId, p, 20);
setItems(res.items);
setTotal(res.total);
setPage(p);
} catch {
toast("加载评论失败");
} finally {
setLoading(false);
}
},
[snippetId]
);
useEffect(() => {
const onRefresh = () => loadComments(page);
window.addEventListener("j13-snippet-comment-refresh", onRefresh);
return () => window.removeEventListener("j13-snippet-comment-refresh", onRefresh);
}, [loadComments, page]);
const handleSubmit = async () => {
const next = content.trim();
if (!next || submitting) return;
setSubmitting(true);
try {
await apiCreateSnippetComment(snippetId, next);
setContent("");
toast("评论成功", "ok");
loadComments(1);
} catch (e) {
toast(e instanceof Error ? e.message : "评论失败");
} finally {
setSubmitting(false);
}
};
const handleUpdate = async (cid: number, newContent: string) => {
await apiUpdateSnippetComment(snippetId, cid, newContent);
loadComments(page);
};
const handleDelete = async (cid: number) => {
await apiDeleteSnippetComment(snippetId, cid);
loadComments(page);
};
const openHistory = (cid: number, currentContent: string) => {
setHistoryCommentContent(currentContent);
setHistoryCommentId(cid);
};
const fetchHistoryPage = async (p: number, size: number) => {
if (!historyCommentId) return { items: [], total: 0, page: p, size };
const res = await apiSnippetCommentHistory(snippetId, historyCommentId, p, size);
const items: EditHistoryItem[] = res.items.map((it) => ({
id: it.id,
editor: it.editor
? {
id: it.editor.id,
username: it.editor.username,
nickname: it.editor.nickname,
avatar: it.editor.avatar,
}
: { id: 0, username: "已注销", nickname: "已注销", avatar: "" },
old_content: it.old_content,
created_at: it.created_at,
}));
return { items, total: res.total, page: res.page, size: res.size };
};
const totalPages = Math.max(1, Math.ceil(total / 20));
return (
<div>
<h2 className="text-[15px] font-bold mb-4 inline-flex items-center gap-2" style={{ color: "var(--ink)" }}>
<MessageSquare size={15} style={{ color: "var(--accent)" }} />
评论({total})
</h2>
{loggedIn ? (
<div className="mb-6 cmt-md-editor">
<MarkdownEditor
id={`snippet-comment-input-${snippetId}`}
density="comment"
value={content}
onChange={(v) => setContent(v.slice(0, COMMENT_MAX))}
maxChars={COMMENT_MAX}
disabled={submitting}
allowHide={false}
ariaLabel="发表评论"
placeholder="写下你的看法,支持 Markdown…"
onUploadingChange={setUploading}
onSubmitShortcut={() => {
void handleSubmit();
}}
footer={
<button
type="button"
onClick={handleSubmit}
disabled={submitting || uploading || !content.trim()}
className="btn btn-primary cmt-submit inline-flex items-center gap-1.5"
>
<Send size={13} />
{submitting ? "发送中…" : "发表评论"}
</button>
}
/>
</div>
) : (
<div
className="mb-6 rounded-xl px-4 py-6 text-center"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
<Link href="/login" className="hover:underline" style={{ color: "var(--accent)" }}>
登录
</Link>{" "}
后参与评论
</p>
</div>
)}
{loading ? (
<p className="meta text-[13px] text-center py-8">加载中…</p>
) : items.length === 0 ? (
<div
className="rounded-xl py-10 text-center"
style={{ background: "var(--panel-2)", border: "1px dashed var(--line-2)" }}
>
<MessageSquare size={20} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
还没有评论,来抢沙发吧
</p>
</div>
) : (
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
{items.map((item) => (
<CommentNode
key={item.id}
item={item}
depth={0}
snippetId={snippetId}
onUpdate={handleUpdate}
onDelete={handleDelete}
onOpenHistory={openHistory}
loggedIn={loggedIn}
/>
))}
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => loadComments(page - 1)}
className="btn btn-line btn-sm"
>
上一页
</button>
<span className="meta text-[12.5px] tabular-nums">
{page} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => loadComments(page + 1)}
className="btn btn-line btn-sm"
>
下一页
</button>
</div>
)}
<EditHistoryModal
open={historyCommentId !== null}
title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={fetchHistoryPage}
onClose={() => setHistoryCommentId(null)}
/>
</div>
);
}