Files
jiang13-bbs/frontend/components/LibrarySearchGrid.tsx
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

254 lines
9.5 KiB
TypeScript
Raw Permalink 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-[17px] font-bold leading-snug text-center line-clamp-3 break-words tracking-wide">
{name}
</span>
</div>
);
}
function ExtChip({ ext, className = "" }: { ext: string; className?: string }) {
return (
<span
className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide ${className}`}
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 w-full aspect-[3/2] 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 ? (
<>
{/* 底层:同图放大模糊填充,竖版/非 3:2 封面不露黑边 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={variantURL(doc.cover_url, 384)}
alt=""
aria-hidden
className="absolute inset-0 w-full h-full object-cover scale-125 blur-lg opacity-70"
loading="lazy"
decoding="async"
draggable={false}
/>
{/* 上层:完整封面居中显示 */}
{/* 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) 100vw, (max-width:1024px) 46vw, (max-width:1280px) 30vw, 300px"
alt={doc.title}
className="absolute inset-0 w-full h-full object-contain 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-[inherit] pointer-events-none"
style={{
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-4">{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 grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{filtered.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
</div>
);
}