396 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|