Files
jiang13-bbs/frontend/app/library/[slug]/page.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

262 lines
9.9 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.
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import Link from "next/link";
import { BookOpen, Download, Paperclip } from "lucide-react";
import { fetchLibraryDocBySlug, type LibraryFileItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { SiteDocMasthead } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
import { estimateReadingMinutes } from "@/lib/toc";
import { groupSections } from "@/lib/librarySections";
import LightboxImage from "@/components/LightboxImage";
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
interface PageProps {
params: Promise<{ slug: string }>;
}
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 无封面时:完整书名 + 书名派生稳定色相渐变块(有图封面走 AdaptiveCoverSlot 按横竖版自适应摆放) */
function CoverFallback({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full aspect-[3/4] rounded-2xl flex items-center justify-center px-6 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="text-[26px] font-extrabold leading-relaxed text-center line-clamp-5 break-all">
{name}
</span>
</div>
);
}
// 附件仅下载分发(书籍正文走阅读页逐章阅读)
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide shrink-0"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function FileRow({ f }: { f: LibraryFileItem }) {
return (
<div
className="rounded-xl px-4 py-3"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
style={{ color: "var(--ink)" }}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
{formatSize(f.size)} · {f.download_count} 次下载
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
>
<Download size={13} /> 下载
</a>
</div>
</div>
);
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
if (!data) return { title: "条目不存在" };
const d = data.doc;
return {
title: d.title,
description: d.description ? d.description.slice(0, 150) : d.title,
openGraph: {
title: d.title,
description: d.description ? d.description.slice(0, 150) : d.title,
type: "article",
publishedTime: d.created_at,
},
...(await requestCanonical(libraryDocPath((await getPublicSettingsCached()).url_style, d.slug))),
};
}
export default async function LibraryDocPage({ params }: PageProps) {
const { slug } = await params;
const [data, settings] = await Promise.all([
fetchLibraryDocBySlug(slug).catch(() => null),
getPublicSettingsCached(),
]);
if (!data) notFound();
const d = data.doc;
const urlStyle = settings.url_style;
const chapterCount = groupSections(d.sections ?? []).length;
const readingMinutes = estimateReadingMinutes(
[d.description, ...(d.sections ?? []).map((s) => s.content)].filter(Boolean).join("\n\n")
);
const jsonLd = webPageJsonLd({
name: d.title,
description: d.description ? d.description.slice(0, 150) : undefined,
path: libraryDocPath(urlStyle, d.slug),
datePublished: d.created_at,
dateModified: d.updated_at,
});
return (
<>
<JsonLd data={jsonLd} />
<div className="max-w-[1200px] 2xl:max-w-[1320px] mx-auto min-w-0">
{/* 手机端水平内边距收窄(px-3),介绍内容近全宽;sm 起恢复常规面板内距 */}
<article className="panel px-3 py-5 sm:p-8">
<SiteDocMasthead
kicker="书库"
title={d.title}
createdAt={d.created_at}
updatedAt={d.updated_at}
showTimeOfDay
readingMinutes={readingMinutes}
badges={
chapterCount > 0 ? (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-2)", border: "1px solid var(--line)" }}
>
{chapterCount} 章
</span>
) : undefined
}
/>
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
{d.cover_url ? (
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
<div
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
</div>
</AdaptiveCoverSlot>
) : null}
<div className="mt-6 lg:grid lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)] lg:gap-8">
{/* 左栏:封面 + 元信息 + 开始阅读 */}
<aside className="lg:sticky lg:top-[84px] self-start">
{d.cover_url ? (
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
<div
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
<div className="flex items-center justify-between">
<dt className="meta">章节数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{chapterCount}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="meta">附件数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{d.files.length}
</dd>
</div>
</dl>
{chapterCount > 0 ? (
<Link
href={libraryDocReadPath(urlStyle, d.slug)}
className="btn btn-primary mt-4 w-full justify-center inline-flex items-center gap-2"
>
<BookOpen size={15} aria-hidden /> 开始阅读
</Link>
) : null}
</aside>
{/* 右栏:介绍 + 附件 */}
<div className="mt-6 lg:mt-0 min-w-0">
<section>
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Paperclip size={15} style={{ color: "var(--accent)" }} aria-hidden />
介绍
</h2>
<div className="mt-3">
{d.description ? (
<MarkdownBody content={d.description} />
) : (
<p className="meta text-[13px]">这本书还没有介绍。</p>
)}
</div>
</section>
<section id="files" className="mt-9 scroll-mt-24">
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
{d.files.length === 0 ? (
<p className="meta text-[12.5px] mt-3">暂无附件</p>
) : (
<div className="mt-3 flex flex-col gap-2.5">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
)}
</section>
</div>
</div>
</article>
<div className="mt-5 flex items-center gap-2 text-[12.5px] meta">
<BookOpen size={14} style={{ color: "var(--accent)" }} aria-hidden />
<span>来自站点书库</span>
</div>
</div>
</>
);
}