feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
261
frontend/app/library/[slug]/page.tsx
Normal file
261
frontend/app/library/[slug]/page.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
frontend/app/library/[slug]/read/page.tsx
Normal file
360
frontend/app/library/[slug]/read/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import type { Components } from "react-markdown";
|
||||
import {
|
||||
fetchLibraryDocBySlug,
|
||||
type LibraryDoc,
|
||||
type LibrarySectionItem,
|
||||
} from "@/lib/api";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
|
||||
import {
|
||||
chapterNav,
|
||||
groupSections,
|
||||
type ChapterNav,
|
||||
} from "@/lib/librarySections";
|
||||
import { extractToc, estimateReadingMinutes, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
|
||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||
import { wrapImplicitEntries } from "@/lib/entryBlocks";
|
||||
import PostToc, { type TocItem } from "@/components/PostToc";
|
||||
import MobileRailDrawers from "@/components/MobileRailDrawers";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ c?: string }>;
|
||||
}
|
||||
|
||||
type SectionGroups = ReturnType<typeof groupSections>;
|
||||
|
||||
interface ReadData {
|
||||
doc: LibraryDoc;
|
||||
groups: SectionGroups;
|
||||
current: LibrarySectionItem | null;
|
||||
nav: ChapterNav;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读页共享取数(generateMetadata 与 page 共用):
|
||||
* fetch 失败/404 → null;?c 为书内章序号(1 起,可写入文章目录地址),
|
||||
* 越界/缺省回落第一章;旧链接传章节全局 id 时兜底按 id 匹配(不报错)
|
||||
*/
|
||||
async function loadRead(slug: string, cParam?: string): Promise<ReadData | null> {
|
||||
const data = await fetchLibraryDocBySlug(slug).catch(() => null);
|
||||
if (!data) return null;
|
||||
const groups = groupSections(data.doc.sections ?? []);
|
||||
const c = Number.parseInt(cParam ?? "", 10);
|
||||
const byNo =
|
||||
Number.isFinite(c) && c >= 1 && c <= groups.length ? groups[c - 1].chapter : undefined;
|
||||
const byId = Number.isFinite(c) ? groups.find((g) => g.chapter.id === c)?.chapter : undefined;
|
||||
const current = byNo ?? byId ?? groups[0]?.chapter ?? null;
|
||||
return { doc: data.doc, groups, current, nav: chapterNav(groups, current?.id ?? null) };
|
||||
}
|
||||
|
||||
/** 为内容块的 markdown 标题挂章内锚点 id(ids 按标题出现顺序对应,与本章目录共享同一份) */
|
||||
function headingComponentsFor(content: string, ids: string[]): Components | undefined {
|
||||
const toc = markdownHeadingToc(extractToc(content || ""));
|
||||
if (toc.length === 0) return undefined;
|
||||
let idx = 0;
|
||||
const render = (Tag: "h2" | "h3") =>
|
||||
function MdHeading({ children }: { children?: React.ReactNode }) {
|
||||
const item = toc[idx++];
|
||||
return (
|
||||
<Tag id={item ? ids[idx - 1] : undefined} style={{ scrollMarginTop: 90 }}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
return { h2: render("h2"), h3: render("h3") };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const { c } = await searchParams;
|
||||
const data = await loadRead(slug, c);
|
||||
if (!data) return { title: "条目不存在" };
|
||||
const title = data.current ? `${data.current.title} · ${data.doc.title}` : data.doc.title;
|
||||
const description = (data.current?.content || data.doc.title).slice(0, 150);
|
||||
// 全部章节共用一个 URL,避免重复收录;详情页是唯一索引入口
|
||||
return { title, description, robots: { index: false } };
|
||||
}
|
||||
|
||||
export default async function LibraryReadPage({ params, searchParams }: PageProps) {
|
||||
const [{ slug }, { c }] = await Promise.all([params, searchParams]);
|
||||
const [data, settings] = await Promise.all([loadRead(slug, c), getPublicSettingsCached()]);
|
||||
if (!data) notFound();
|
||||
|
||||
const urlStyle = settings.url_style;
|
||||
const d = data.doc;
|
||||
// 无章节条目:阅读页无内容可渲染,回详情页(介绍 + 附件)
|
||||
if (!data.current) redirect(libraryDocPath(urlStyle, d.slug));
|
||||
const current = data.current;
|
||||
const { groups, nav } = data;
|
||||
const subs = groups[nav.index]?.subs ?? [];
|
||||
|
||||
// 章内锚点统一 e-{sec}-{n}:sec 为书内章序号(与地址 ?sec= 一致),n 为章内 1 基顺序号,
|
||||
// 按渲染顺序(章首 markdown 标题/条目卡标题 → 各小节及其标题/条目)由同一计数器分配,
|
||||
// 目录、正文 markdown 标题、卡内条目标题共享同一份 id,可手写目录链接如 ?sec=1#e-1-1
|
||||
// 条目卡模式先做与渲染一致的规范化(标题包壳后再提取),避免目录锚点错位
|
||||
const normOf = (content: string) =>
|
||||
d.entries_auto ? wrapImplicitEntries(content || "") : content || "";
|
||||
const segsOf = (content: string) =>
|
||||
splitContentSegments(normOf(content), { entriesAuto: d.entries_auto });
|
||||
const secNo = nav.index + 1;
|
||||
let anchorSeq = 0;
|
||||
const nextAnchor = () => `e-${secNo}-${++anchorSeq}`;
|
||||
const chToc = buildTocFromSegments(segsOf(current.content), nextAnchor);
|
||||
// 小节:titleId 仍由页面 h3 直接挂载,其内容目录来自分段构建
|
||||
const subAnchors = subs.map((sub) => ({
|
||||
titleId: nextAnchor(),
|
||||
toc: buildTocFromSegments(segsOf(sub.content), nextAnchor),
|
||||
}));
|
||||
|
||||
// 右栏目录不重复章名(正文标题、左栏章节列表均已可见),章首条目/小节/小节内标题直接铺开,
|
||||
// 省掉章名占位行,让有限宽度的每行能显示更长的标题
|
||||
const chapterToc: TocItem[] = [...chToc.items];
|
||||
subAnchors.forEach((a, si) => {
|
||||
chapterToc.push({ level: 2, text: subs[si].title, id: a.titleId });
|
||||
chapterToc.push(...a.toc.items);
|
||||
});
|
||||
const hasChapterToc = chapterToc.length > 0;
|
||||
|
||||
const detail = libraryDocPath(urlStyle, d.slug);
|
||||
const chapterMinutes = estimateReadingMinutes(
|
||||
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
|
||||
);
|
||||
|
||||
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
|
||||
const bookRail = (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href={detail}
|
||||
className="inline-flex items-center gap-1.5 meta text-[12px] shrink-0 hover:text-[var(--accent)]"
|
||||
>
|
||||
<ArrowLeft size={13} aria-hidden /> 书籍详情
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={detail}
|
||||
className="block rounded-xl p-1 -m-1 hover:bg-[var(--panel-2)] transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-[13.5px] font-bold leading-snug line-clamp-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{d.title}
|
||||
</p>
|
||||
<p className="meta text-[11.5px] mt-1">
|
||||
{nav.total} 章 · 更新于 {formatRelative(d.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav aria-label="章节目录">
|
||||
<p
|
||||
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2 px-1"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
目录
|
||||
</p>
|
||||
<ul className="space-y-0.5" style={{ borderLeft: "1px solid var(--line)" }}>
|
||||
{groups.map((g, i) => {
|
||||
const active = g.chapter.id === current.id;
|
||||
return (
|
||||
<li key={g.chapter.id}>
|
||||
<Link
|
||||
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
|
||||
className={`j13-toc-link${active ? " is-active" : ""}`}
|
||||
aria-current={active ? "location" : undefined}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate">{g.chapter.title}</span>
|
||||
</span>
|
||||
</Link>
|
||||
{/* 当前章的节:页内锚点(e-{sec}-{n});其他章的节:跳到该章 */}
|
||||
{g.subs.map((sub, si) =>
|
||||
active ? (
|
||||
<a key={sub.id} href={`#${subAnchors[si].titleId}`} className="j13-toc-link is-h3">
|
||||
{sub.title}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={sub.id}
|
||||
href={libraryDocReadPath(urlStyle, d.slug, i + 1)}
|
||||
className="j13-toc-link is-h3"
|
||||
>
|
||||
{sub.title}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{/* 移动端:吸顶工具栏 + 左右抽屉(目录 / 本章),lg 起隐藏;返回详情入口在左抽屉内 */}
|
||||
<MobileRailDrawers
|
||||
center={
|
||||
<>
|
||||
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
|
||||
{current.title}
|
||||
</p>
|
||||
<p className="meta mt-0.5 text-[10.5px] leading-tight">
|
||||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
left={{ label: "目录", content: bookRail }}
|
||||
right={
|
||||
hasChapterToc
|
||||
? {
|
||||
label: "本章",
|
||||
// 抽屉容器是无高度约束的滚动区,需包一层 h-full 限高 flex,
|
||||
// PostToc 内列表才能成为滚动容器:否则列表不滚动(overscroll-behavior
|
||||
// 阻断滚动链到抽屉容器)且跟随阅读位置的定位滚动落空
|
||||
content: (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<PostToc items={chapterToc} title="本章目录" bare />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
hasChapterToc
|
||||
? "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-8 xl:grid-cols-[280px_minmax(0,1fr)_280px] xl:gap-6"
|
||||
: "lg:grid lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)] lg:gap-8"
|
||||
}
|
||||
>
|
||||
{/* 左侧书栏:详情入口 + 章节目录树(与移动端左抽屉共用 bookRail 内容) */}
|
||||
<aside className="hidden lg:block min-w-0">
|
||||
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] overflow-y-auto j13-rail-scroll panel p-4">
|
||||
{bookRail}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
|
||||
<div className="j13-doc-bleed min-w-0">
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<header>
|
||||
<p className="meta text-[12px]">
|
||||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
||||
</p>
|
||||
<h2
|
||||
id="ch-top"
|
||||
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{current.title}
|
||||
</h2>
|
||||
</header>
|
||||
{current.content ? (
|
||||
<div className="mt-5">
|
||||
<MarkdownBody
|
||||
content={current.content}
|
||||
components={headingComponentsFor(normOf(current.content), chToc.mdHeadIds)}
|
||||
entriesAuto={d.entries_auto}
|
||||
entriesTitleIds={chToc.entriesTitleIds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{subs.map((sub, si) => (
|
||||
<section key={sub.id} className="mt-8">
|
||||
<h3
|
||||
id={subAnchors[si].titleId}
|
||||
className="scroll-mt-24 text-[17px] sm:text-[18px] font-bold"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{sub.title}
|
||||
</h3>
|
||||
{sub.content ? (
|
||||
<div className="mt-3">
|
||||
<MarkdownBody
|
||||
content={sub.content}
|
||||
components={headingComponentsFor(normOf(sub.content), subAnchors[si].toc.mdHeadIds)}
|
||||
entriesAuto={d.entries_auto}
|
||||
entriesTitleIds={subAnchors[si].toc.entriesTitleIds}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</article>
|
||||
|
||||
<nav className="mt-5 grid gap-3 sm:grid-cols-2" aria-label="章节导航">
|
||||
{nav.prev ? (
|
||||
<Link
|
||||
href={libraryDocReadPath(urlStyle, d.slug, nav.index)}
|
||||
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors"
|
||||
>
|
||||
<span className="meta text-[11.5px] inline-flex items-center gap-1">
|
||||
<ArrowLeft size={12} aria-hidden /> 上一章
|
||||
</span>
|
||||
<span
|
||||
className="mt-1 block text-[13.5px] font-semibold truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{nav.prev.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="panel p-4 opacity-50 select-none">
|
||||
<span className="meta text-[11.5px]">上一章</span>
|
||||
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||
已是第一章
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nav.next ? (
|
||||
<Link
|
||||
href={libraryDocReadPath(urlStyle, d.slug, nav.index + 2)}
|
||||
className="panel p-4 min-w-0 hover:bg-[var(--panel-2)] transition-colors sm:text-right"
|
||||
>
|
||||
<span className="meta text-[11.5px] inline-flex items-center gap-1">
|
||||
下一章 <ArrowRight size={12} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="mt-1 block text-[13.5px] font-semibold truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{nav.next.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="panel p-4 opacity-50 select-none sm:text-right">
|
||||
<span className="meta text-[11.5px]">下一章</span>
|
||||
<span className="mt-1 block text-[13.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||
已是最后一章
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 右栏:章节内目录树(区域足够时宽屏常驻,滚动时可随时跳转)。
|
||||
外层仅做 sticky 定位(flex-col + max-h 限高),滚动由 PostToc 内列表承担:
|
||||
避免外层 overflow-y:auto 与内层列表的 overflow-y:auto 形成双层滚动容器,
|
||||
滚轮路由到不可滚的内层导致滚轮失效(只能拖滚动条) */}
|
||||
{hasChapterToc ? (
|
||||
<aside className="hidden xl:block min-w-0">
|
||||
<div className="sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] flex flex-col">
|
||||
<PostToc items={chapterToc} title="本章目录" bare />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
frontend/app/library/page.tsx
Normal file
178
frontend/app/library/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { CSSProperties } from "react";
|
||||
import Link from "next/link";
|
||||
import { BookOpen, FileDown } from "lucide-react";
|
||||
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
|
||||
import { coverHue } from "@/lib/coverColor";
|
||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { libraryPath, libraryDocPath } from "@/lib/urlStyle";
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
|
||||
function TextCover({ title }: { title: string }) {
|
||||
const name = title.trim() || "书";
|
||||
return (
|
||||
<div
|
||||
className="j13-text-cover w-full h-full flex items-center justify-center px-5 select-none"
|
||||
style={{ "--cover-h": coverHue(name) } as CSSProperties}
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
className="text-[22px] font-extrabold leading-snug text-center line-clamp-3 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"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({
|
||||
doc,
|
||||
href,
|
||||
}: {
|
||||
doc: LibraryDocListItem;
|
||||
href: string;
|
||||
}) {
|
||||
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
|
||||
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col overflow-hidden rounded-[20px] transition-transform hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div className="h-36 overflow-hidden" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
{doc.cover_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={doc.cover_url}
|
||||
alt={doc.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<TextCover title={doc.title} />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-2 flex-1">
|
||||
<h2
|
||||
className="text-[15px] font-bold leading-snug line-clamp-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{doc.title}
|
||||
</h2>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1.5">
|
||||
{exts.map((ext) => (
|
||||
<ExtChip key={ext} ext={ext} />
|
||||
))}
|
||||
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1">
|
||||
<FileDown size={11} />
|
||||
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getPublicSettingsCached();
|
||||
return {
|
||||
title: "书库",
|
||||
description: settings.site_description,
|
||||
...(await requestCanonical(libraryPath(settings.url_style))),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LibraryPage() {
|
||||
const settings = await getPublicSettingsCached();
|
||||
const urlStyle = settings.url_style;
|
||||
|
||||
// 接口失败降级为空态,不让页面 500
|
||||
let docs: LibraryDocListItem[] = [];
|
||||
try {
|
||||
const res = await fetchLibraryList();
|
||||
docs = res.docs ?? [];
|
||||
} catch {
|
||||
/* 降级空态 */
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{/* Hero:渐变洗底(与排行榜同语言) */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
|
||||
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
|
||||
filter: "blur(48px)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<h1
|
||||
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight inline-flex items-center gap-2.5"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<BookOpen size={26} style={{ color: "var(--accent)" }} />
|
||||
书库
|
||||
</h1>
|
||||
<p className="meta mt-2 text-[13.5px]">书籍、文档与页面作品,在线阅读或下载</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{docs.length === 0 ? (
|
||||
<div
|
||||
className="mt-6 rounded-[20px] px-6 py-14 text-center"
|
||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
书库还没有内容
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-1.5">管理员可在后台「书库管理」发布条目</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{docs.map((d) => (
|
||||
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user