新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|