Files
mnote/wolai-frontend/src/components/media/image-picker-dialog.tsx
T
2026-01-02 07:25:50 +08:00

396 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { MediaAsset, MediaKind, MediaSelection } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
export type PickerTab = "upload" | "recent" | "link" | "icon";
interface ImagePickerDialogProps {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
documentId: string;
workspaceId: string;
onClose: () => void;
onSelect: (selection: MediaSelection) => void;
}
const ICON_PRESETS: Array<{ id: string; label: string; url: string }> = [
{ id: "progress", label: "进度波纹", url: "https://media.giphy.com/media/QBd2kLB5qDmysEXre9/giphy.gif" },
{ id: "heartbeat", label: "心跳", url: "https://media.giphy.com/media/HEPwfdu6T6svpPE1eN/giphy.gif" },
{ id: "sparkles", label: "闪光", url: "https://media.giphy.com/media/f9jQLaPmuFKfa7E55Q/giphy.gif" },
{ id: "loading", label: "加载", url: "https://media.giphy.com/media/52qtwCtj9OLTi/giphy.gif" },
];
const MEDIA_TABS: Record<MediaKind, PickerTab[]> = {
image: ["upload", "recent", "link", "icon"],
video: ["upload", "recent", "link"],
audio: ["upload", "recent", "link"],
file: ["upload", "recent", "link"],
};
const TAB_LABELS: Record<PickerTab, string> = {
upload: "上传",
recent: "最近",
link: "链接",
icon: "动态图标",
};
const MEDIA_ACCEPTS: Record<MediaKind, Record<string, string[]> | null> = {
image: { "image/*": [] },
video: { "video/*": [] },
audio: { "audio/*": [] },
file: null,
};
const MEDIA_TITLES: Record<MediaKind, string> = {
image: "选择图片",
video: "选择视频",
audio: "选择音频",
file: "选择文件",
};
const formatBytes = (size?: number | null) => {
if (!size || size <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = size;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[index]}`;
};
const guessFileNameFromUrl = (url: string) => {
try {
const parsed = new URL(url);
const name = parsed.pathname.split("/").filter(Boolean).pop();
if (name) {
return decodeURIComponent(name);
}
} catch {
const segments = url.split("?")[0]?.split("/") ?? [];
const name = segments.pop();
if (name) {
return decodeURIComponent(name);
}
}
return "未命名资源";
};
export function ImagePickerDialog({
open,
defaultTab,
mediaType,
documentId,
workspaceId,
onClose,
onSelect,
}: ImagePickerDialogProps) {
const [tab, setTab] = useState<PickerTab>(defaultTab);
const availableTabs = MEDIA_TABS[mediaType];
const [recent, setRecent] = useState<MediaAsset[]>([]);
const [recentLoading, setRecentLoading] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
const availableTabs = MEDIA_TABS[mediaType];
const fallback = availableTabs.includes(defaultTab) ? defaultTab : availableTabs[0];
setTab(fallback);
}
}, [defaultTab, mediaType, open]);
const fetchRecent = useCallback(async () => {
if (!workspaceId) {
return;
}
setRecentLoading(true);
try {
const response = await fetch(
`/api/media/assets?workspaceId=${workspaceId}&assetType=${mediaType}`,
);
if (!response.ok) {
throw new Error("无法获取最近上传");
}
const payload = (await response.json()) as { items: MediaAsset[] };
setRecent(payload.items ?? []);
} catch (err) {
console.error(err);
setRecent([]);
} finally {
setRecentLoading(false);
}
}, [workspaceId, mediaType]);
useEffect(() => {
if (open && tab === "recent") {
void fetchRecent();
}
}, [open, tab, fetchRecent]);
const handleUpload = useCallback(
async (file: File) => {
if (!workspaceId || !documentId) {
setError("缺少必要参数");
return;
}
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
emitAssetsChanged(documentId, payload.asset);
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
onClose();
} catch (err) {
setError((err as Error).message);
} finally {
setUploading(false);
}
},
[documentId, onClose, onSelect, workspaceId],
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
void handleUpload(acceptedFiles[0]);
},
[handleUpload],
);
const acceptConfig = MEDIA_ACCEPTS[mediaType];
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptConfig ?? undefined,
maxFiles: 1,
});
const handleLinkSubmit = async () => {
if (!linkUrl.trim()) return;
try {
const targetUrl = linkUrl.trim();
const response = await fetch("/api/media/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId,
documentId,
fileUrl: targetUrl,
thumbnailUrl: targetUrl,
assetType: mediaType,
fileName: guessFileNameFromUrl(targetUrl),
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "保存失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("缺少图片地址");
}
emitAssetsChanged(documentId, payload.asset);
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
setLinkUrl("");
onClose();
} catch (err) {
setError((err as Error).message);
}
};
const renderRecent = () => {
if (recentLoading) {
return <div className="py-8 text-center text-sm text-gray-500">加载最近上传中...</div>;
}
if (recent.length === 0) {
return <div className="py-8 text-center text-sm text-gray-400">暂无最近上传的{MEDIA_TITLES[mediaType].replace("选择", "")}</div>;
}
return (
<div className="grid grid-cols-3 gap-3">
{recent.map((asset) => (
<button
type="button"
key={asset.id}
className="rounded-xl border border-gray-200 bg-white p-2 text-left shadow-sm transition hover:border-[#2563eb]"
onClick={() => {
if (!asset.file_url) return;
onSelect({
assetId: asset.id,
fileUrl: asset.file_url,
thumbnailUrl: asset.thumbnail_url,
assetType: asset.asset_type,
fileName: asset.file_name,
fileSize: asset.file_size,
mimeType: asset.mime_type,
});
onClose();
}}
>
{mediaType === "image" ? (
<img
src={asset.thumbnail_url ?? asset.file_url ?? ""}
alt={asset.asset_type}
className="h-32 w-full rounded-lg object-cover"
/>
) : (
<div className="flex h-32 flex-col items-start justify-center gap-1 rounded-lg bg-gray-50 px-3">
<p className="text-sm font-medium text-gray-700">{asset.file_name ?? "未命名资源"}</p>
{asset.file_size ? <p className="text-xs text-gray-500">{formatBytes(asset.file_size)}</p> : null}
</div>
)}
<p className="mt-2 truncate text-xs text-gray-500">
{asset.asset_type === "icon" ? "动态图标" : MEDIA_TITLES[mediaType].replace("选择", "")}
</p>
</button>
))}
</div>
);
};
const renderIcons = () => (
<div className="grid grid-cols-2 gap-3">
{ICON_PRESETS.map((icon) => (
<button
key={icon.id}
type="button"
className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 p-3 text-sm text-gray-600 transition hover:border-[#2563eb]"
onClick={() => {
onSelect({
assetId: `icon-${icon.id}`,
fileUrl: icon.url,
thumbnailUrl: icon.url,
assetType: "icon",
});
onClose();
}}
>
<img src={icon.url} alt={icon.label} className="h-24 w-24 rounded-lg object-cover" />
<span className="mt-2 text-xs">{icon.label}</span>
</button>
))}
</div>
);
const helperText = useMemo(() => {
const label = MEDIA_TITLES[mediaType].replace("选择", "");
switch (tab) {
case "upload":
return `支持拖拽或点击上传${label},单个文件不超过 200MB`;
case "recent":
return `最近 12 个${label},支持一键选取`;
case "link":
return `输入${label}直链或 CDN 地址`;
case "icon":
return "选择 Wolai 常用动态图标";
default:
return "";
}
}, [mediaType, tab]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader className="text-left">
<DialogTitle>{MEDIA_TITLES[mediaType]}</DialogTitle>
<DialogDescription>{helperText}</DialogDescription>
</DialogHeader>
{error && <div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
<Tabs value={tab} onValueChange={(value) => setTab(value as PickerTab)}>
<TabsList className="mb-4">
{availableTabs.map((tabKey) => (
<TabsTrigger key={tabKey} value={tabKey}>
{TAB_LABELS[tabKey]}
</TabsTrigger>
))}
</TabsList>
{availableTabs.includes("upload") && (
<TabsContent value="upload">
<div
{...getRootProps()}
className={cn(
"flex h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 text-sm text-gray-500",
isDragActive && "border-[#2563eb] bg-[#f5f7ff]",
)}
>
<input {...getInputProps()} />
{uploading ? "上传中..." : "拖拽文件到此处,或点击选择文件"}
</div>
</TabsContent>
)}
{availableTabs.includes("recent") && (
<TabsContent value="recent">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-gray-500">最近上传的{MEDIA_TITLES[mediaType].replace("选择", "")}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => void fetchRecent()}>
刷新
</Button>
</div>
{renderRecent()}
</TabsContent>
)}
{availableTabs.includes("link") && (
<TabsContent value="link">
<div className="flex gap-2">
<Input
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
placeholder={`输入${MEDIA_TITLES[mediaType].replace("选择", "")}链接,例如 https://example.com/file`}
/>
<Button type="button" onClick={() => void handleLinkSubmit()}>
插入
</Button>
</div>
</TabsContent>
)}
{availableTabs.includes("icon") && <TabsContent value="icon">{renderIcons()}</TabsContent>}
</Tabs>
</DialogContent>
</Dialog>
);
}