- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
238 lines
7.8 KiB
TypeScript
238 lines
7.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|