Files
jiang13-bbs/frontend/components/ImageInsertModal.tsx
freefire 31f5360c26 feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话
- Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal)
- 实时总线、账号守卫与用户事件适配
2026-09-26 01:10:10 +08:00

236 lines
7.8 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";
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</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>
);
}