Files
jiang13-bbs/frontend/components/ImageInsertModal.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

238 lines
7.8 KiB
TypeScript
Raw 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";
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={item.url}
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>
);
}