Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
@@ -0,0 +1,204 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { DecryptedImage } from "@/components/common/decrypted-image";
import { COMMENT_MAX_LENGTH, COMMENT_SORTS, formatCommentTime, type CommentTarget } from "./data";
import { CommentReplyPreview } from "./comment-reply-preview";
import { HeartIcon } from "./heart-icon";
import { InlineReplyBox } from "./inline-reply-box";
import { useComments } from "./use-comments";
function SheetHead({ title, onClose, children }: { title: ReactNode; onClose: () => void; children?: ReactNode }) {
return (
<div className="flex items-center gap-3">
<button
type="button"
aria-label="收起"
onClick={onClose}
className="cursor-pointer flex size-6 items-center justify-center text-white"
>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
<h2 className="min-w-0 flex-1 text-center text-base leading-[22px] font-medium text-white">{title}</h2>
{children ?? <span className="size-6" />}
</div>
);
}
/** H5 短剧播放页评论底部弹层 */
export function H5CommentSheet({ onClose, target }: { onClose: () => void; target: CommentTarget | null }) {
const {
sort,
setSort,
comments,
total,
hasMore,
loading,
loadingMore,
error,
loadMore,
toggleLike,
post,
posting,
postError,
reload,
} = useComments({ target });
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<{ id: number; userName: string } | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = text.trim();
if (!content || posting || replyTo) return;
const ok = await post(content);
if (ok) setText("");
};
const submitReply = async (content: string) => {
if (!replyTo) return false;
const ok = await post(content, { parentsId: replyTo.id });
if (ok) setReplyTo(null);
return ok;
};
return (
<div data-component="h5-comment-sheet" className="flex h-[70vh] flex-col">
<div className="px-4 pt-[18px] pb-3">
<SheetHead title={`评论 ${total}`} onClose={onClose}>
<div className="flex gap-3 text-xs leading-[18px]">
{COMMENT_SORTS.map((item) => (
<button
key={item}
type="button"
aria-pressed={sort === item}
onClick={() => setSort(item)}
className={`cursor-pointer ${sort === item ? "text-white" : "text-white/35"}`}
>
{item}
</button>
))}
</div>
</SheetHead>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4">
{loading ? (
<p className="py-10 text-center text-sm text-white/50">评论加载中…</p>
) : error ? (
<div className="flex flex-col items-center gap-3 py-10">
<p className="text-sm text-white/50">{error}</p>
<button
type="button"
onClick={reload}
className="cursor-pointer rounded-md bg-white/10 px-4 py-2 text-xs text-white"
>
重试
</button>
</div>
) : comments.length === 0 ? (
<p className="py-10 text-center text-sm text-white/50">暂无评论</p>
) : (
<ul>
{comments.map((comment) => {
const liked = Boolean(comment.isLike);
const commentId = Number(comment.id) || 0;
const isReplying = replyTo?.id === commentId;
return (
<li key={comment.id} className="flex gap-3 border-b border-white/5 py-3">
<DecryptedImage
src={comment.avatar}
fallback="/images/default_avatar.svg"
alt=""
className="size-8 shrink-0 rounded-full object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="text-xs leading-[18px] text-white/50">{comment.userName}</p>
<p className="text-sm leading-5 text-white">{comment.text}</p>
<div className="flex items-center gap-3 text-[11px] leading-4 text-white/35">
<span>{formatCommentTime(comment.createdAt)}</span>
<button
type="button"
aria-pressed={liked}
onClick={() => void toggleLike(comment)}
className="cursor-pointer flex items-center gap-1"
>
<HeartIcon filled={liked} />
{comment.countLike ?? 0}
</button>
<button
type="button"
onClick={() => {
if (isReplying) {
setReplyTo(null);
return;
}
if (!Number.isInteger(commentId) || commentId < 1) return;
setReplyTo({
id: commentId,
userName: (comment.userName || "").trim() || "用户",
});
}}
className={`cursor-pointer ${isReplying ? "text-[#F12B9E]" : "hover:text-white"}`}
>
回复
</button>
</div>
{isReplying && replyTo ? (
<InlineReplyBox
compact
userName={replyTo.userName}
posting={posting}
error={postError}
onSubmit={submitReply}
onCancel={() => setReplyTo(null)}
/>
) : null}
<CommentReplyPreview
comment={comment}
className="rounded-md bg-white/5 px-2.5 py-2 text-xs leading-[18px]"
textClassName="text-white/70"
/>
</div>
</li>
);
})}
</ul>
)}
{!loading && !error && comments.length > 0 ? (
<button
type="button"
disabled={!hasMore || loadingMore}
onClick={loadMore}
className="cursor-pointer my-3 h-9 w-full rounded-md bg-white/5 text-xs text-white/50 disabled:opacity-50"
>
{loadingMore ? "加载中…" : hasMore ? "加载更多" : "没有更多了"}
</button>
) : null}
</div>
<div className="border-t border-white/5 px-4 py-2.5">
{!replyTo && postError ? <p className="mb-2 text-xs text-[#FF8FA8]">{postError}</p> : null}
<form onSubmit={submit} className="flex items-center gap-2.5">
<input
value={text}
maxLength={COMMENT_MAX_LENGTH}
disabled={posting || Boolean(replyTo)}
onChange={(event) => setText(event.target.value)}
placeholder="发表评论…"
aria-label="评论内容"
className="h-9 min-w-0 flex-1 rounded-full bg-white/5 px-4 text-sm text-white outline-none placeholder:text-white/35 disabled:opacity-60"
/>
<button
type="submit"
disabled={!text.trim() || posting || Boolean(replyTo)}
className="cursor-pointer h-9 shrink-0 rounded-full bg-[#F12B9E] px-4 text-sm text-white disabled:opacity-50"
>
{posting && !replyTo ? "发送中" : "发送"}
</button>
</form>
</div>
</div>
);
}