Files
jiang13-bbs/frontend/app/library/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

179 lines
5.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 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>
);
}