Files
jiang13-bbs/frontend/components/ImageInsertModal.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

241 lines
8.1 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 { useEffect, useState } from "react";
import { Images, Link2, Loader2, Upload } from "lucide-react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
type InsertTab = "upload" | "url" | "media";
/**
* 编辑器插图统一弹窗:上传本地文件 / 图片链接 / 从本人媒体库选择。
* 上传与插入逻辑由父组件处理(需要拿到正文光标),这里只负责选择与展示。
*/
export default function ImageInsertModal({
open,
onClose,
uploading,
uploadError,
onChooseFile,
onDropFile,
onInsertUrl,
onPickMedia,
}: {
open: boolean;
onClose: () => void;
/** 图片上传进行中(来自编辑器,禁用操作并展示进度) */
uploading: boolean;
/** 上传失败的提示(也可重试),来自编辑器底部横幅同一状态 */
uploadError: string;
/** 打开系统文件选择框(选择后走编辑器上传流程) */
onChooseFile: () => void;
/** 拖拽图片到上传区 */
onDropFile: (file: File) => void;
/** 链接 tab 提交;校验失败由父组件 toast 并保持弹窗 */
onInsertUrl: (url: string, alt: string) => void;
/** 媒体库选中一张图片 */
onPickMedia: (item: MediaAttachment) => void;
}) {
const [tab, setTab] = useState<InsertTab>("upload");
const [url, setUrl] = useState("https://");
const [alt, setAlt] = useState("图片");
const [dragOver, setDragOver] = useState(false);
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [mediaLoading, setMediaLoading] = useState(false);
const [mediaError, setMediaError] = useState("");
useEffect(() => {
if (!open) return;
setTab("upload");
setUrl("https://");
setAlt("图片");
setDragOver(false);
}, [open]);
// 切到「我的图片」时拉取本人媒体库(每次进入都刷新,与上传后可见最新一致)
useEffect(() => {
if (!open || tab !== "media") return;
let cancelled = false;
setMediaLoading(true);
setMediaError("");
void apiMyMedia()
.then((res) => {
if (cancelled) return;
if (res.error) {
setMediaError(res.error);
setMedia([]);
return;
}
setMedia((res.attachments || []).filter((item) => item.kind === "image"));
})
.catch(() => {
if (!cancelled) setMediaError("加载媒体库失败");
})
.finally(() => {
if (!cancelled) setMediaLoading(false);
});
return () => {
cancelled = true;
};
}, [open, tab]);
const tabs: { key: InsertTab; label: string; icon: React.ReactNode }[] = [
{ key: "upload", label: "上传", icon: <Upload size={13} /> },
{ key: "url", label: "链接", icon: <Link2 size={13} /> },
{ key: "media", label: "我的图片", icon: <Images size={13} /> },
];
return (
<Modal
open={open}
title="插入图片"
description="上传本地文件、粘贴链接,或从已上传库中选择。"
onClose={onClose}
maxWidthClass="max-w-lg"
dismissOnOverlay
>
<div className="j13-md-mode j13-img-tabs" role="tablist" aria-label="插图方式">
{tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={tab === t.key ? "is-on" : ""}
disabled={uploading}
onClick={() => setTab(t.key)}
>
{t.icon} {t.label}
</button>
))}
</div>
{tab === "upload" && (
<div className="mt-4">
<button
type="button"
className={`j13-img-dropzone${dragOver ? " is-dragover" : ""}`}
disabled={uploading}
onClick={onChooseFile}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
if (uploading) return;
const file = e.dataTransfer.files?.[0];
if (file) onDropFile(file);
}}
>
<Upload size={22} aria-hidden />
<span className="j13-img-dropzone-title">
{uploading ? "图片上传中…" : "拖拽图片到此处,或点击选择文件"}
</span>
<span className="j13-img-dropzone-hint">
支持 JPEG / PNG / WebP / GIF,自动转存 WebP(GIF 除外)
</span>
</button>
{uploadError && !uploading ? (
<p className="j13-img-modal-error" role="alert">
{uploadError}
</p>
) : null}
</div>
)}
{tab === "url" && (
<div className="mt-4 flex flex-col gap-3">
<label className="j13-md-field">
<span>说明</span>
<input
value={alt}
onChange={(e) => setAlt(e.target.value)}
className="j13-md-popover-input"
/>
</label>
<label className="j13-md-field">
<span>URL</span>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
className="j13-md-popover-input"
placeholder="https://…"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onInsertUrl(url, alt);
}
}}
/>
</label>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={uploading}
onClick={() => onInsertUrl(url, alt)}
>
插入
</button>
</div>
</div>
)}
{tab === "media" && (
<div className="mt-4">
{mediaLoading ? (
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
<Loader2 size={14} className="animate-spin" /> 加载中
</p>
) : mediaError ? (
<p className="j13-img-modal-error">{mediaError}</p>
) : media.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">
媒体库还没有图片。发帖时上传或粘贴图片后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{media.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPickMedia(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.width}×${item.height}`}
>
{/* 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>
)}
</div>
)}
</Modal>
);
}