feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
@@ -14,11 +14,14 @@ export default function Avatar({
|
||||
size = 36,
|
||||
src,
|
||||
className = "",
|
||||
square = false,
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
src?: string | null;
|
||||
className?: string;
|
||||
/** 方形(带小圆角)轮廓,用于帖子列表行;默认圆形 */
|
||||
square?: boolean;
|
||||
}) {
|
||||
const idx = hash(name || "?") % 8;
|
||||
const initial = (name || "?").trim().charAt(0).toUpperCase();
|
||||
@@ -30,7 +33,9 @@ export default function Avatar({
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center shrink-0 select-none font-bold rounded-full overflow-hidden av-${idx} ${className}`}
|
||||
className={`inline-flex items-center justify-center shrink-0 select-none font-bold overflow-hidden av-${idx} ${
|
||||
square ? "rounded-[9px]" : "rounded-full"
|
||||
} ${className}`}
|
||||
style={{ width: size, height: size, fontSize: size * 0.42 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PinnedPosts({
|
||||
animationDelay: `${i * 35}ms`,
|
||||
}}
|
||||
>
|
||||
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} />
|
||||
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} square />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||
|
||||
@@ -61,7 +61,7 @@ export default async function PostRow({
|
||||
aria-label={authorName}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} />
|
||||
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} square />
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -165,6 +165,16 @@ export default function TimelineEditor({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const keptCount = items.filter((it) => it.selected !== false).length;
|
||||
const urlCount = urlsText
|
||||
.split(/\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean).length;
|
||||
const hasExistingContent = items.some(
|
||||
(it) => it.title.trim() || it.body.trim()
|
||||
);
|
||||
const showReplaceWarning = importMode === "replace" && hasExistingContent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -173,19 +183,36 @@ export default function TimelineEditor({
|
||||
onClose={onClose}
|
||||
zIndexClass="z-[1200]"
|
||||
maxWidthClass="max-w-2xl"
|
||||
panelClassName="p-0 overflow-hidden"
|
||||
description="从提交记录批量导入,或手动编辑后插入正文。"
|
||||
panelClassName="p-5 sm:p-6 max-h-[88vh] flex flex-col overflow-hidden"
|
||||
bodyClassName="flex-1 min-h-0 flex flex-col"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
|
||||
{initialBody.trim() ? "更新时间线" : "插入时间线"}
|
||||
</button>
|
||||
</>
|
||||
<div className="w-full flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="text-[12.5px]"
|
||||
style={{ color: keptCount > 0 ? "var(--ink-3)" : "var(--danger)" }}
|
||||
>
|
||||
{keptCount > 0
|
||||
? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条`
|
||||
: "没有可保留的条目"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleInsert}
|
||||
disabled={keptCount === 0}
|
||||
>
|
||||
{initialBody.trim() ? "更新时间线" : "插入时间线"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
|
||||
{/* 导入区 */}
|
||||
<section className="j13-tl-import" aria-label="从提交记录导入">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -195,16 +222,22 @@ export default function TimelineEditor({
|
||||
</p>
|
||||
</div>
|
||||
<p className="meta text-[12px] mb-2 leading-relaxed">
|
||||
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
|
||||
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
|
||||
</p>
|
||||
<textarea
|
||||
value={urlsText}
|
||||
onChange={(e) => setUrlsText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setUrlsText(e.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
|
||||
className="j13-tl-urls"
|
||||
disabled={importing}
|
||||
/>
|
||||
{urlCount > 0 ? (
|
||||
<p className="j13-tl-hint">已识别 {urlCount} 条 URL</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||
<input
|
||||
@@ -242,18 +275,24 @@ export default function TimelineEditor({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{importError ? (
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
{importError}
|
||||
{showReplaceWarning ? (
|
||||
<p className="j13-tl-banner is-warn">
|
||||
替换模式会丢弃当前已编辑的 {items.length} 条内容;如需保留请改选「追加」。
|
||||
</p>
|
||||
) : null}
|
||||
{importError ? (
|
||||
<p className="j13-tl-banner is-err">{importError}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
时间线条目
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
时间线条目
|
||||
</p>
|
||||
<span className="j13-tl-count is-muted">{items.length} 项</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
|
||||
按日期整理
|
||||
@@ -268,80 +307,99 @@ export default function TimelineEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{items.map((it, idx) => (
|
||||
<li key={idx} className="j13-tl-edit-card">
|
||||
<div className="flex items-start gap-2">
|
||||
<label className="mt-2 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={it.selected !== false}
|
||||
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
|
||||
aria-label="保留此条"
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="j13-tl-empty">
|
||||
<p>还没有时间线条目</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setItems([emptyItem()])}
|
||||
>
|
||||
<Plus size={14} /> 添加一条
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{items.map((it, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className={`j13-tl-edit-card${it.selected === false ? " is-excluded" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<label className="mt-2 shrink-0">
|
||||
<input
|
||||
type="date"
|
||||
value={it.date}
|
||||
onChange={(e) => updateItem(idx, { date: e.target.value })}
|
||||
className="j13-tl-field w-[9.5rem]"
|
||||
type="checkbox"
|
||||
checked={it.selected !== false}
|
||||
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
|
||||
aria-label="保留此条"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={it.title}
|
||||
maxLength={200}
|
||||
placeholder="标题"
|
||||
onChange={(e) => updateItem(idx, { title: e.target.value })}
|
||||
className="j13-tl-field flex-1 min-w-[10rem]"
|
||||
</label>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{it.selected === false ? (
|
||||
<span className="j13-tl-tag">已排除</span>
|
||||
) : null}
|
||||
<input
|
||||
type="date"
|
||||
value={it.date}
|
||||
onChange={(e) => updateItem(idx, { date: e.target.value })}
|
||||
className="j13-tl-field w-[9.5rem]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={it.title}
|
||||
maxLength={200}
|
||||
placeholder="标题"
|
||||
onChange={(e) => updateItem(idx, { title: e.target.value })}
|
||||
className="j13-tl-field flex-1 min-w-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={it.body}
|
||||
rows={2}
|
||||
placeholder="说明(可选,支持 Markdown)"
|
||||
onChange={(e) => updateItem(idx, { body: e.target.value })}
|
||||
className="j13-tl-field w-full"
|
||||
/>
|
||||
{it.sourceUrl ? (
|
||||
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
|
||||
来自提交 · {it.sourceUrl}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="上移"
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveItem(idx, -1)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="下移"
|
||||
disabled={idx === items.length - 1}
|
||||
onClick={() => moveItem(idx, 1)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="删除"
|
||||
onClick={() => setDeleteIdx(idx)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={it.body}
|
||||
rows={2}
|
||||
placeholder="说明(可选,支持 Markdown)"
|
||||
onChange={(e) => updateItem(idx, { body: e.target.value })}
|
||||
className="j13-tl-field w-full"
|
||||
/>
|
||||
{it.sourceUrl ? (
|
||||
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
|
||||
来自提交 · {it.sourceUrl}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="上移"
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveItem(idx, -1)}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="下移"
|
||||
disabled={idx === items.length - 1}
|
||||
onClick={() => moveItem(idx, 1)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
aria-label="删除"
|
||||
onClick={() => setDeleteIdx(idx)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ export default function VisitBeacon() {
|
||||
const now = Date.now();
|
||||
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
|
||||
lastRef.current = { path: pathname, at: now };
|
||||
void apiTelemetryPageView();
|
||||
// 带 query 完整路径 + 来源页,供后台来源分析
|
||||
const full = pathname + (typeof window !== "undefined" ? window.location.search : "");
|
||||
const ref = typeof document !== "undefined" ? document.referrer : "";
|
||||
void apiTelemetryPageView(full, ref);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
FileText,
|
||||
CircleDollarSign,
|
||||
Columns3,
|
||||
PanelLeft,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -64,6 +66,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
title: "概览",
|
||||
items: [
|
||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
||||
{ href: "/admin/analytics", label: "访问统计", icon: BarChart3, visible: (u) => isSuperOrOwner(u.role) },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -145,6 +148,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Palette,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/sidebar",
|
||||
label: "侧栏管理",
|
||||
icon: PanelLeft,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -25,7 +25,7 @@ import { formatCompact } from "@/lib/format";
|
||||
|
||||
/* ---------- 1. 板块管理员 ---------- */
|
||||
|
||||
function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
export function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="板块管理员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -56,7 +56,7 @@ function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
|
||||
/* ---------- 2. 板块概况 ---------- */
|
||||
|
||||
function BoardIntro({ board }: { board: BoardWithCount }) {
|
||||
export function BoardIntro({ board }: { board: BoardWithCount }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="板块概况">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -82,7 +82,7 @@ function BoardIntro({ board }: { board: BoardWithCount }) {
|
||||
|
||||
/* ---------- 3. 板块数据 + 签到 ---------- */
|
||||
|
||||
function BoardPulse({
|
||||
export function BoardPulse({
|
||||
stats,
|
||||
checkin,
|
||||
allowComments,
|
||||
@@ -156,7 +156,7 @@ function BoardPulse({
|
||||
|
||||
/* ---------- 4. 本板热门 ---------- */
|
||||
|
||||
function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="本板热门">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
@@ -196,7 +196,7 @@ function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowCommen
|
||||
|
||||
/* ---------- 5. 本板活跃 ---------- */
|
||||
|
||||
function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="本板活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
|
||||
@@ -38,7 +38,7 @@ import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads"
|
||||
|
||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||
|
||||
function PulseCard({
|
||||
export function PulseCard({
|
||||
stats,
|
||||
checkin,
|
||||
allowComments,
|
||||
@@ -130,7 +130,7 @@ function PulseCard({
|
||||
|
||||
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
|
||||
|
||||
function AboutSite({
|
||||
export function AboutSite({
|
||||
pages,
|
||||
siteDescription,
|
||||
canManagePages,
|
||||
@@ -175,7 +175,7 @@ function AboutSite({
|
||||
|
||||
/* ---------- 站点公告 ---------- */
|
||||
|
||||
function Announcements({
|
||||
export function Announcements({
|
||||
items,
|
||||
canManageAnnouncements,
|
||||
}: {
|
||||
@@ -248,7 +248,7 @@ function Announcements({
|
||||
|
||||
/* ---------- 热门榜 ---------- */
|
||||
|
||||
function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="热门讨论">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
@@ -288,7 +288,7 @@ function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComm
|
||||
|
||||
/* ---------- 最新注册 ---------- */
|
||||
|
||||
function NewUsers({ users }: { users: NewUser[] }) {
|
||||
export function NewUsers({ users }: { users: NewUser[] }) {
|
||||
if (users.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module" aria-label="最新注册">
|
||||
@@ -323,7 +323,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
|
||||
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
||||
|
||||
function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="本周活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
@@ -383,7 +383,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
|
||||
|
||||
/* ---------- 友情链接 ---------- */
|
||||
|
||||
function FriendLinks() {
|
||||
export function FriendLinks() {
|
||||
if (FRIEND_LINKS.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module" aria-label="友情链接">
|
||||
@@ -441,7 +441,7 @@ type PanelsProps = {
|
||||
sponsorsEnabled?: boolean;
|
||||
};
|
||||
|
||||
function AdsPanel({
|
||||
export function AdsPanel({
|
||||
items,
|
||||
title,
|
||||
enabled,
|
||||
@@ -529,7 +529,7 @@ function AdsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorsPanel({
|
||||
export function SponsorsPanel({
|
||||
items,
|
||||
title,
|
||||
enabled,
|
||||
|
||||
@@ -12,81 +12,95 @@ function boardHref(id: number) {
|
||||
return `/?board=${id}`;
|
||||
}
|
||||
|
||||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
// 板块目录裸面板:只有 j13-module 本体,不含外层 nav / 宽度 / sticky 类,
|
||||
// 供侧栏注册表按管理端配置在任意侧栏渲染。
|
||||
export function WorkspaceNavPanel({
|
||||
boards,
|
||||
user,
|
||||
}: {
|
||||
boards: BoardWithCount[];
|
||||
user?: User | null;
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const showAdmin = !!user && isStaff(user.role);
|
||||
|
||||
return (
|
||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
||||
<p className="j13-module-title shrink-0">
|
||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
板块目录
|
||||
{boards.length > 0 ? (
|
||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||
{boards.length} 个
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Home size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||
</Link>
|
||||
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAdmin ? (
|
||||
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Link href="/admin" className="j13-bcard">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ShieldCheck size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
return (
|
||||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||||
<nav
|
||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
|
||||
aria-label="工作台导航"
|
||||
>
|
||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
|
||||
<p className="j13-module-title shrink-0">
|
||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
板块目录
|
||||
{boards.length > 0 ? (
|
||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||
{boards.length} 个
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Home size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||
</Link>
|
||||
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAdmin ? (
|
||||
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Link href="/admin" className="j13-bcard">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ShieldCheck size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<WorkspaceNavPanel boards={boards} user={user} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
170
frontend/components/post/PostRailBlocks.tsx
Normal file
170
frontend/components/post/PostRailBlocks.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import Link from "next/link";
|
||||
import { Layers, ArrowRight, User, Flame, PenLine } from "lucide-react";
|
||||
import type { UserProfile, PostListItem, Board } from "@/lib/api";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
|
||||
// formatYMD 帖子详情页左栏「作者其他帖」日期
|
||||
function formatYMD(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
|
||||
d.getDate()
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
帖子详情页侧栏块(自 page.tsx 原样迁出,供侧栏注册表按配置渲染)
|
||||
============================================================ */
|
||||
|
||||
export function BoardCard({ board }: { board: Board }) {
|
||||
const colorIdx = boardColor(board);
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
||||
<p className="j13-module-title mb-3" id="board-card-title">
|
||||
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
||||
</p>
|
||||
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
||||
<span
|
||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
|
||||
>
|
||||
<BoardIcon icon={board.icon} name={board.name} size={20} />
|
||||
</span>
|
||||
<span
|
||||
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{board.name}
|
||||
</span>
|
||||
</Link>
|
||||
{board.description && (
|
||||
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{board.description}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/?board=${board.id}`}
|
||||
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
||||
>
|
||||
查看该板讨论 <ArrowRight size={13} />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorRailCard({
|
||||
profile,
|
||||
allowComments,
|
||||
}: {
|
||||
profile: UserProfile;
|
||||
allowComments: boolean;
|
||||
}) {
|
||||
const u = profile.user;
|
||||
const signature = (u.signature || "").trim();
|
||||
const { post_count, comment_count, points, streak } = profile.stats;
|
||||
const stats = [
|
||||
{ label: "发帖", value: post_count },
|
||||
...(allowComments ? [{ label: "评论", value: comment_count }] : []),
|
||||
{ label: "积分", value: points },
|
||||
];
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
|
||||
<p className="j13-module-title mb-3" id="author-card-title">
|
||||
<User size={15} style={{ color: "var(--accent)" }} /> 作者
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{u.nickname}
|
||||
</Link>
|
||||
<p className="meta mt-0.5 truncate">@{u.username}</p>
|
||||
{u.role !== "user" && (
|
||||
<div className="mt-1">
|
||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 有签名则展示;无则留空,不放占位文案 */}
|
||||
{signature ? (
|
||||
<p
|
||||
className="mt-3 text-[12.5px] leading-relaxed line-clamp-3"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
title={signature}
|
||||
>
|
||||
{signature}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
|
||||
style={{ borderColor: "var(--line)" }}
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="px-1.5 py-2.5 text-center"
|
||||
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
|
||||
>
|
||||
<p
|
||||
className="text-[17px] font-semibold leading-none tabular-nums"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
{s.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{streak > 0 && (
|
||||
<p
|
||||
className="mt-2 flex items-center justify-center gap-1 text-[11.5px] font-medium"
|
||||
style={{ color: "var(--gold)" }}
|
||||
>
|
||||
<Flame size={11.5} /> 连签 {streak} 天
|
||||
</p>
|
||||
)}
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
|
||||
访问主页
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||
if (posts.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
|
||||
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
|
||||
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
|
||||
</p>
|
||||
<ul className="j13-rail-scroll">
|
||||
{posts.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/post/${p.id}`}
|
||||
data-tip={p.title}
|
||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
>
|
||||
<span
|
||||
className="block text-[13px] leading-snug line-clamp-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{p.title}
|
||||
</span>
|
||||
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
frontend/components/sidebar/CustomWidgetBlock.tsx
Normal file
54
frontend/components/sidebar/CustomWidgetBlock.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SidebarWidgetPublic, User } from "@/lib/api";
|
||||
import { scopeWidgetCss } from "@/lib/widgetCode";
|
||||
import { ensureWidgetApi } from "@/lib/widgetApi";
|
||||
|
||||
// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
|
||||
// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
|
||||
// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
|
||||
// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
|
||||
export default function CustomWidgetBlock({
|
||||
widget,
|
||||
viewer,
|
||||
}: {
|
||||
widget: SidebarWidgetPublic;
|
||||
viewer?: User | null;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
|
||||
useEffect(() => {
|
||||
ensureWidgetApi(viewer);
|
||||
}, [viewer]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !widget.js) return;
|
||||
const script = document.createElement("script");
|
||||
script.dataset.widgetScript = String(widget.id);
|
||||
script.textContent = widget.js;
|
||||
host.appendChild(script);
|
||||
return () => {
|
||||
script.remove();
|
||||
};
|
||||
}, [widget.id, widget.js]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`j13-widget-body j13-widget-${widget.id}`}
|
||||
data-widget-id={widget.id}
|
||||
data-widget-name={widget.name}
|
||||
>
|
||||
{widget.css ? (
|
||||
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
|
||||
) : null}
|
||||
{widget.html ? (
|
||||
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
|
||||
) : null}
|
||||
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
|
||||
<div ref={hostRef} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
frontend/components/sidebar/SidebarRenderer.tsx
Normal file
40
frontend/components/sidebar/SidebarRenderer.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Fragment } from "react";
|
||||
import type { SidebarBlockRef, SidebarWidgetPublic } from "@/lib/sidebarMeta";
|
||||
import { renderSidebarBlock, type SidebarBlockProps } from "./registry";
|
||||
|
||||
// 页面从本文件导入该类型(registry 中定义)
|
||||
export type { SidebarBlockProps };
|
||||
import CustomWidgetBlock from "./CustomWidgetBlock";
|
||||
|
||||
// 按管理端配置渲染一列侧栏块:
|
||||
// - 内置块经注册表解析,未知 key / 数据缺失静默跳过;
|
||||
// - 自定义工具引用已删除或停用(公开载荷不含)时静默跳过;
|
||||
// - className 缺省时输出 Fragment,布局容器(gap/space-y、sticky、限高)由页面自行决定。
|
||||
export default function SidebarRenderer({
|
||||
blocks,
|
||||
widgets,
|
||||
props,
|
||||
className,
|
||||
}: {
|
||||
blocks: SidebarBlockRef[];
|
||||
widgets: SidebarWidgetPublic[];
|
||||
props: SidebarBlockProps;
|
||||
className?: string;
|
||||
}) {
|
||||
const items = blocks
|
||||
.map((ref, i) => {
|
||||
if (ref.widget) {
|
||||
const widget = widgets.find((x) => x.id === ref.widget);
|
||||
if (!widget) return null;
|
||||
return <CustomWidgetBlock key={`w-${widget.id}-${i}`} widget={widget} viewer={props.user} />;
|
||||
}
|
||||
if (!ref.key) return null;
|
||||
const node = renderSidebarBlock(ref.key, props);
|
||||
if (node === null) return null;
|
||||
return <Fragment key={`k-${ref.key}-${i}`}>{node}</Fragment>;
|
||||
})
|
||||
.filter((x) => x !== null);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
return className ? <div className={className}>{items}</div> : <>{items}</>;
|
||||
}
|
||||
155
frontend/components/sidebar/registry.tsx
Normal file
155
frontend/components/sidebar/registry.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
ActiveUser,
|
||||
AnnouncementSummary,
|
||||
Board,
|
||||
BoardModerator,
|
||||
BoardSidebarStats,
|
||||
BoardWithCount,
|
||||
CheckinStatus,
|
||||
NewUser,
|
||||
OverviewStats,
|
||||
PostListItem,
|
||||
PublicAdItem,
|
||||
PublicSponsorItem,
|
||||
SidebarPageItem,
|
||||
User,
|
||||
UserProfile,
|
||||
} from "@/lib/api";
|
||||
import PostToc, { type TocItem } from "@/components/PostToc";
|
||||
import { WorkspaceNavPanel } from "@/components/home/WorkspaceNav";
|
||||
import {
|
||||
PulseCard,
|
||||
Announcements,
|
||||
AdsPanel,
|
||||
SponsorsPanel,
|
||||
HotRanking,
|
||||
NewUsers,
|
||||
ActiveUsers,
|
||||
FriendLinks,
|
||||
AboutSite,
|
||||
} from "@/components/home/CommunityPanels";
|
||||
import {
|
||||
Moderators,
|
||||
BoardIntro,
|
||||
BoardPulse,
|
||||
BoardHot,
|
||||
BoardActive,
|
||||
} from "@/components/home/BoardPanels";
|
||||
import {
|
||||
BoardCard as PostBoardCard,
|
||||
AuthorRailCard as PostAuthorCard,
|
||||
AuthorPosts as PostAuthorPosts,
|
||||
} from "@/components/post/PostRailBlocks";
|
||||
|
||||
// 侧栏块 props 汇总:页面把已有数据整包传入,块按需取用;
|
||||
// 某块所需数据缺失(如板块态没传 stats)时对应块渲染为空,安全跳过。
|
||||
export interface SidebarBlockProps {
|
||||
allowComments: boolean;
|
||||
user?: User | null;
|
||||
checkin?: CheckinStatus | null;
|
||||
|
||||
// home 范围
|
||||
boards?: BoardWithCount[];
|
||||
stats?: OverviewStats;
|
||||
announcements?: AnnouncementSummary[];
|
||||
canManageAnnouncements?: boolean;
|
||||
ads?: PublicAdItem[];
|
||||
adsPanelTitle?: string;
|
||||
adsEnabled?: boolean;
|
||||
sponsors?: PublicSponsorItem[];
|
||||
sponsorsPanelTitle?: string;
|
||||
sponsorsEnabled?: boolean;
|
||||
hot?: PostListItem[];
|
||||
newUsers?: NewUser[];
|
||||
activeUsers?: ActiveUser[];
|
||||
sidebarPages?: SidebarPageItem[];
|
||||
siteDescription?: string;
|
||||
canManagePages?: boolean;
|
||||
|
||||
// board 范围
|
||||
board?: BoardWithCount;
|
||||
moderators?: BoardModerator[];
|
||||
boardStats?: BoardSidebarStats;
|
||||
boardHot?: PostListItem[];
|
||||
boardActiveUsers?: ActiveUser[];
|
||||
|
||||
// post 范围
|
||||
postBoard?: Board;
|
||||
authorProfile?: UserProfile;
|
||||
authorPosts?: PostListItem[];
|
||||
toc?: TocItem[];
|
||||
}
|
||||
|
||||
// 内置块注册表:key → 组件。数据未就绪或未知 key 返回 null(渲染层静默跳过)。
|
||||
export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
|
||||
switch (key) {
|
||||
case "board_nav":
|
||||
return p.boards ? <WorkspaceNavPanel boards={p.boards} user={p.user} /> : null;
|
||||
case "pulse":
|
||||
return p.stats ? (
|
||||
<PulseCard stats={p.stats} checkin={p.checkin} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "announcements":
|
||||
return p.announcements ? (
|
||||
<Announcements items={p.announcements} canManageAnnouncements={p.canManageAnnouncements} />
|
||||
) : null;
|
||||
case "ads":
|
||||
return p.ads && p.adsEnabled !== false ? (
|
||||
<AdsPanel items={p.ads} title={p.adsPanelTitle || "自助推广"} enabled />
|
||||
) : null;
|
||||
case "sponsors":
|
||||
return p.sponsors && p.sponsorsEnabled !== false ? (
|
||||
<SponsorsPanel
|
||||
items={p.sponsors}
|
||||
title={p.sponsorsPanelTitle || "赞助商"}
|
||||
enabled
|
||||
/>
|
||||
) : null;
|
||||
case "hot":
|
||||
return p.hot ? <HotRanking posts={p.hot} allowComments={p.allowComments} /> : null;
|
||||
case "new_users":
|
||||
return p.newUsers ? <NewUsers users={p.newUsers} /> : null;
|
||||
case "active_users":
|
||||
return p.activeUsers ? (
|
||||
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "friend_links":
|
||||
return <FriendLinks />;
|
||||
case "about_site":
|
||||
return p.sidebarPages || p.canManagePages ? (
|
||||
<AboutSite
|
||||
pages={p.sidebarPages ?? []}
|
||||
siteDescription={p.siteDescription}
|
||||
canManagePages={p.canManagePages}
|
||||
/>
|
||||
) : null;
|
||||
case "board_moderators":
|
||||
return p.moderators ? <Moderators items={p.moderators} /> : null;
|
||||
case "board_intro":
|
||||
return p.board ? <BoardIntro board={p.board} /> : null;
|
||||
case "board_pulse":
|
||||
return p.boardStats ? (
|
||||
<BoardPulse stats={p.boardStats} checkin={p.checkin} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "board_hot":
|
||||
return p.boardHot ? (
|
||||
<BoardHot posts={p.boardHot} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "board_active":
|
||||
return p.boardActiveUsers ? (
|
||||
<BoardActive users={p.boardActiveUsers} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "post_board":
|
||||
return p.postBoard ? <PostBoardCard board={p.postBoard} /> : null;
|
||||
case "post_author":
|
||||
return p.authorProfile ? (
|
||||
<PostAuthorCard profile={p.authorProfile} allowComments={p.allowComments} />
|
||||
) : null;
|
||||
case "post_author_posts":
|
||||
return p.authorPosts ? <PostAuthorPosts posts={p.authorPosts} /> : null;
|
||||
case "post_toc":
|
||||
return p.toc && p.toc.length > 0 ? <PostToc items={p.toc} /> : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user