新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
262 lines
9.9 KiB
TypeScript
262 lines
9.9 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|