Files
jiang13-bbs/frontend/components/markdown/MdHideBlock.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

231 lines
7.4 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 type { ReactNode, FormEvent } from "react";
import { Lock } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useId, useState } from "react";
import { apiUnlockPost, apiUnlockPostPassword } from "@/lib/api";
import { CSRF_COOKIE } from "@/lib/cookies";
import { toast } from "@/lib/toast";
import { hideKindLabel, type HideKind } from "@/lib/hideBlocks";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 若浏览器尚无 CSRF cookie,请求公开接口以触发后端签发 */
async function ensureGuestCSRF() {
if (typeof document === "undefined") return;
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`(?:^|;\\s*)${escaped}=`).test(document.cookie)) return;
try {
await fetch("/api/me", { credentials: "include", cache: "no-store" });
} catch {
/* 忽略:后续解锁会报 CSRF 错误 */
}
}
type MdHideBlockProps = {
kind: HideKind;
points?: number;
locked?: boolean;
postId?: number;
/** 登录跳转路径(公告等无 postId 时使用) */
loginHref?: string;
loggedIn?: boolean;
children?: ReactNode;
};
/** 正文内嵌隐藏块:已解锁显示徽章容器;locked 显示就地门禁 */
export default function MdHideBlock({
kind,
points = 0,
locked = false,
postId,
loginHref,
loggedIn = false,
children,
}: MdHideBlockProps) {
const router = useRouter();
const urlStyle = useUrlStyle();
const pwdErrId = useId();
const [busy, setBusy] = useState(false);
const [pwd, setPwd] = useState("");
const [pwdError, setPwdError] = useState("");
const label = hideKindLabel(kind);
const loginTo =
loginHref ||
(postId
? `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`
: "/login");
if (locked) {
const unlockPoints = async () => {
if (!postId) return;
setBusy(true);
try {
const res = await apiUnlockPost(String(postId));
if (res.post) {
toast("已解锁隐藏内容", "ok");
router.refresh();
} else {
toast(res.error || "解锁失败", "error");
}
} catch {
toast("解锁失败", "error");
} finally {
setBusy(false);
}
};
const unlockPassword = async (e: FormEvent) => {
e.preventDefault();
if (!postId) return;
const password = pwd.trim();
if (!password) {
setPwdError("请输入密码");
return;
}
setBusy(true);
setPwdError("");
try {
// 游客可能尚无 CSRF:先打一次公开 GET 让后端签发,再提交解锁
await ensureGuestCSRF();
const res = await apiUnlockPostPassword(String(postId), password);
if (res.post) {
toast("密码正确,已解锁", "ok");
setPwd("");
setPwdError("");
router.refresh();
} else {
setPwdError(res.error || "密码错误,请重试");
}
} catch {
setPwdError("验证失败,请稍后重试");
} finally {
setBusy(false);
}
};
return (
<aside
className="j13-hide-gate"
role="note"
aria-label={`${label}(未解锁)`}
>
<Lock size={22} className="j13-hide-gate-icon" aria-hidden />
<p className="j13-hide-gate-title">此段内容{label}</p>
<p className="j13-hide-gate-desc meta">
{kind === "points"
? `支付 ${points || 0} 积分后可阅读此段`
: kind === "reply"
? loggedIn
? "发表一条回复后即可阅读此段"
: "登录并发表一条回复后即可阅读此段"
: kind === "password"
? "输入正确密码后可阅读此段"
: "登录后即可阅读此段"}
</p>
<div className="j13-hide-gate-actions">
{kind === "login" && !loggedIn && (
<Link href={loginTo} className="btn btn-primary">
去登录
</Link>
)}
{kind === "points" && loggedIn && postId && (
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void unlockPoints()}
>
{busy ? "解锁中…" : `支付 ${points || 0} 积分解锁`}
</button>
)}
{kind === "points" && !loggedIn && (
<Link href={loginTo} className="btn btn-primary">
登录后解锁
</Link>
)}
{kind === "reply" && !loggedIn && (
<Link
href={
postId
? `/login?redirect=${encodeURIComponent(`${postPath(urlStyle, postId)}#comment-input`)}`
: loginTo
}
className="btn btn-primary"
>
登录后去回复
</Link>
)}
{kind === "reply" && loggedIn && postId && (
<a
href="#comment-input"
className="btn btn-primary"
onClick={(e) => {
e.preventDefault();
const el = document.getElementById("comment-input");
if (!(el instanceof HTMLTextAreaElement)) {
document.getElementById("comments")?.scrollIntoView({
behavior: "smooth",
block: "start",
});
return;
}
el.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
el.focus({ preventScroll: true });
}, 280);
}}
>
去回复
</a>
)}
{kind === "password" && postId && (
<div className="j13-hide-pwd-wrap">
<form className="j13-hide-pwd-form" onSubmit={(e) => void unlockPassword(e)}>
<input
type="password"
className={`j13-hide-pwd-input${pwdError ? " is-invalid" : ""}`}
placeholder="输入密码"
value={pwd}
maxLength={64}
autoComplete="off"
disabled={busy}
aria-invalid={pwdError ? true : undefined}
aria-describedby={pwdError ? pwdErrId : undefined}
onChange={(e) => {
setPwd(e.target.value);
if (pwdError) setPwdError("");
}}
aria-label="隐藏内容密码"
/>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? "验证中…" : "查看"}
</button>
</form>
{pwdError && (
<p id={pwdErrId} className="j13-hide-pwd-error" role="alert">
{pwdError}
</p>
)}
</div>
)}
</div>
</aside>
);
}
return (
<div className="j13-hide-block" data-hide-kind={kind}>
<div className="j13-hide-block-bar">
<span className="j13-hide-badge">{label}</span>
{kind === "points" && points > 0 && (
<span className="meta text-[12px]">{points} 积分</span>
)}
</div>
<div className="j13-hide-block-body">{children}</div>
</div>
);
}