Files
jiang13-bbs/frontend/components/LibrarySearchGrid.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

242 lines
9.0 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.
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import { FileDown, PenLine, Search, X } from "lucide-react";
import type { LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { libraryDocPath, type UrlStyle } from "@/lib/urlStyle";
import { variantURL, widthSrcSet } from "@/lib/responsiveImage";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
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`;
}
/** 简介 markdown → 纯文本摘要(去标记、压平空白,卡片两行截断用) */
function plainExcerpt(md: string): string {
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // 图片
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 链接保留文字
.replace(/[*_~`#>]+/g, "") // 行内/块级标记
.replace(/\s+/g, " ")
.trim();
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(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-4 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
{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);
const creatorName = doc.creator ? doc.creator.nickname || doc.creator.username : "";
const excerpt = plainExcerpt(doc.description ?? "");
return (
<Link href={href} className="group flex flex-col min-w-0">
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
<div
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
style={{ background: "var(--panel-2)" }}
>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={variantURL(doc.cover_url, 384)}
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
{/* 书脊/书口光影,营造实体书体积感 */}
<div
aria-hidden
className="absolute inset-0 rounded-[10px] pointer-events-none"
style={{
background:
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
}}
/>
{excerpt ? (
<div
className="absolute inset-0 flex items-end p-3.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
style={{
background:
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
}}
>
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
</div>
) : null}
</div>
{/* 封面下方书目信息:衬线书名 + 作者 + 文件概要 */}
<div className="pt-3 flex flex-col gap-1 min-w-0">
<h2
className="j13-library-title text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
{doc.author ? (
<p className="meta flex items-center gap-1.5 text-[12px] min-w-0">
<PenLine size={11} className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden />
<span className="truncate" title={doc.author}>
{doc.author}
</span>
</p>
) : null}
<div className="mt-1 flex items-center gap-1.5 min-w-0">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1 shrink-0">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
{doc.creator ? (
<span className="flex items-center gap-1.5 min-w-0 shrink">
<Avatar size={16} name={creatorName} src={doc.creator.avatar} />
<span className="meta text-[11px] truncate max-w-[6rem]" title={creatorName}>
{creatorName}
</span>
</span>
) : null}
<span className="meta text-[11px] truncate shrink min-w-0">
{doc.creator ? "· " : ""}
{formatRelative(doc.created_at)}
</span>
</div>
</div>
</Link>
);
}
/**
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
*/
export default function LibrarySearchGrid({
docs,
urlStyle,
}: {
docs: LibraryDocListItem[];
urlStyle: UrlStyle;
}) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!q) return docs;
return docs.filter(
(d) => d.title.toLowerCase().includes(q) || d.author.toLowerCase().includes(q)
);
}, [docs, q]);
if (docs.length === 0) {
return (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[15px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[13px] mt-2">管理员可在后台「书库管理」发布条目</p>
</div>
);
}
return (
<div className="mt-2">
{/* 搜索:居中宽框,与大气 Hero 呼应 */}
<div className="relative max-w-lg mx-auto">
<Search
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索书名或作者"
aria-label="搜索书名或作者"
className="j13-search-field w-full rounded-full pl-11 pr-10 py-3 text-[14px] shadow-[var(--shadow-sm)]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
) : null}
</div>
{filtered.length === 0 ? (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
没有找到与「{query.trim()}」相关的书籍
</p>
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
</div>
) : (
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{filtered.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
</div>
);
}