新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
"use client";
|
||
|
||
import { Images, Loader2 } from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import Modal from "@/components/Modal";
|
||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||
|
||
/** 来源标签映射 */
|
||
const SOURCE_LABELS: Record<string, string> = {
|
||
library_cover: "书籍封面",
|
||
library_content: "书籍内容",
|
||
post: "帖子插图",
|
||
};
|
||
|
||
function sourceLabel(source?: string): string {
|
||
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
|
||
}
|
||
|
||
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
|
||
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
|
||
const groups = new Map<string, MediaAttachment[]>();
|
||
for (const item of items) {
|
||
const key = item.source || "post";
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key)!.push(item);
|
||
}
|
||
// 按优先级排序
|
||
const order = ["library_cover", "library_content", "post"];
|
||
return Array.from(groups.entries()).sort(
|
||
([a], [b]) => order.indexOf(a) - order.indexOf(b)
|
||
);
|
||
}
|
||
|
||
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
|
||
export default function LibraryCoverPicker({
|
||
open,
|
||
onClose,
|
||
onPick,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onPick: (item: MediaAttachment) => void;
|
||
}) {
|
||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setError("");
|
||
void apiMyMedia()
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
if (res.error) {
|
||
setError(res.error);
|
||
setItems([]);
|
||
return;
|
||
}
|
||
setItems((res.attachments || []).filter((item) => item.kind === "image"));
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setError("加载媒体库失败");
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open]);
|
||
|
||
const groups = groupBySource(items);
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
title="从图库选择封面"
|
||
description="已选作封面的图片会单独分组,再次选择直接复用。"
|
||
onClose={onClose}
|
||
maxWidthClass="max-w-2xl"
|
||
dismissOnOverlay
|
||
>
|
||
{loading ? (
|
||
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||
<Loader2 size={14} className="animate-spin" /> 加载中
|
||
</p>
|
||
) : error ? (
|
||
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
|
||
{error}
|
||
</p>
|
||
) : items.length === 0 ? (
|
||
<p className="meta text-[13px] py-6 text-center">
|
||
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
|
||
</p>
|
||
) : (
|
||
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
|
||
{groups.map(([source, groupItems]) => (
|
||
<section key={source}>
|
||
<h4
|
||
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
{sourceLabel(source)}
|
||
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
|
||
</h4>
|
||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||
{groupItems.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => {
|
||
onPick(item);
|
||
onClose();
|
||
}}
|
||
className="relative group aspect-square rounded-xl overflow-hidden"
|
||
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||
data-tip={
|
||
item.source === "library_cover"
|
||
? "已作封面,点击直接复用"
|
||
: "复制一份用作封面"
|
||
}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||
sizes="(max-width:520px) 23vw, 96px"
|
||
alt="图库候选封面"
|
||
className="w-full h-full object-contain"
|
||
loading="lazy"
|
||
/>
|
||
<span
|
||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||
>
|
||
<Images size={14} />
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|