feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
241
frontend/components/LibrarySearchGrid.tsx
Normal file
241
frontend/components/LibrarySearchGrid.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user