Support paste file to upload

This commit is contained in:
oiov
2025-07-13 10:44:16 +08:00
parent 9eb296cf51
commit 34f73ca0e5
5 changed files with 314 additions and 251 deletions
+64 -3
View File
@@ -1,6 +1,13 @@
"use client";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import React, {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone";
@@ -17,6 +24,8 @@ const DragAndDrop = ({
bucketInfo: BucketInfo;
}) => {
const t = useTranslations("Components");
const containerRef = useRef<HTMLDivElement>(null);
const [isPasteActive, setIsPasteActive] = useState(false);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
@@ -25,15 +34,63 @@ const DragAndDrop = ({
[setSelectedFile],
);
const handlePaste = useCallback(
(event: ClipboardEvent) => {
const clipboardData = event.clipboardData;
if (!clipboardData) {
return;
}
const items = clipboardData.items;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
// 检查是否是文件类型
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length > 0) {
event.preventDefault();
setSelectedFile(files);
setIsPasteActive(true);
setTimeout(() => setIsPasteActive(false), 2000);
}
},
[setSelectedFile],
);
useEffect(() => {
const handleGlobalPaste = (event: ClipboardEvent) => {
handlePaste(event);
};
document.addEventListener("paste", handleGlobalPaste);
return () => {
document.removeEventListener("paste", handleGlobalPaste);
};
}, [handlePaste]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div
ref={containerRef}
{...getRootProps()}
className={`grids flex h-52 w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-4 duration-150 ${
tabIndex={0}
className={`grids flex h-52 w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-4 duration-150 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
isDragActive
? "border-opacity-90 bg-muted/80 backdrop-blur-[2px]"
: "border-opacity-50 bg-muted/10 backdrop-blur-[1px]"
: isPasteActive
? "border-green-500 bg-green-50 backdrop-blur-[2px]"
: "border-opacity-50 bg-muted/10 backdrop-blur-[1px]"
}`}
>
<input {...getInputProps()} />
@@ -45,6 +102,10 @@ const DragAndDrop = ({
<div className="animate-fade-in text-primary">
{t("Drop files to upload them to")} {bucketInfo.bucket}
</div>
) : isPasteActive ? (
<div className="animate-fade-in text-green-600">
{t("Files pasted successfully")}
</div>
) : (
<div className="animate-fade-out">
<p>{t("Drag and drop file(s) here")}</p>
+245 -245
View File
@@ -25,6 +25,8 @@ import {
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
} from "@/components/ui/drawer";
import { CopyButton } from "@/components/shared/copy-button";
@@ -91,270 +93,268 @@ export const FileUploader = ({
</Button>
{isOpen && (
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerContent className="h-screen w-full overflow-y-auto rounded-none sm:max-w-xl">
<DrawerHeader className="flex items-center justify-between">
<DrawerTitle className="flex items-center gap-1">
{t("Upload Files")}
</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost" className="">
<Icons.close className="size-4" />
</Button>
</DrawerClose>
</DrawerHeader>
<DrawerDescription className="flex items-center justify-between px-4">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<div className="truncate">{bucketInfo.provider_name}</div>
<Icons.arrowRight className="size-3" />
<div className="font-medium text-blue-600 dark:text-blue-400">
{bucketInfo.bucket}
</div>
<DrawerPortal>
<DrawerContent className="h-screen w-full overflow-y-auto rounded-none sm:max-w-xl">
<div className="mb-2 flex items-center justify-between">
<p className="mx-4 text-lg font-bold">{t("Upload Files")}</p>
<DrawerClose asChild>
<Button variant="ghost">
<Icons.close className="size-4" />
</Button>
</DrawerClose>
</div>
<Badge className="text-xs">
{t("Limit")}:{" "}
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
precision: 0,
})}{" "}
/{" "}
{formatFileSize(Number(plan?.stMaxTotalSize || "0"), {
precision: 0,
})}
</Badge>
</DrawerDescription>
<div className="space-y-3 p-4">
<DragAndDrop
setSelectedFile={setSelectedFile}
bucketInfo={bucketInfo}
/>
{/* 统计信息 */}
{stats.total > 0 && (
<div className="flex items-center justify-between gap-3">
<h2 className="font-semibold">{t("Upload List")}</h2>
<Badge className="flex items-center gap-1">
{stats.completed}
<span>/</span>
{stats.total}
</Badge>
<div className="flex items-center justify-between px-4">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<div className="truncate">{bucketInfo.provider_name}</div>
<Icons.arrowRight className="size-3" />
<div className="font-medium text-blue-600 dark:text-blue-400">
{bucketInfo.bucket}
</div>
</div>
// <div className="mt-6 rounded-lg bg-gray-50 p-4">
// <div className="flex flex-wrap gap-4 text-sm">
// <span className="text-gray-600">总计: {stats.total}</span>
// <span className="text-gray-600">等待: {stats.pending}</span>
// <span className="text-blue-600">
// 上传中: {stats.uploading}
// </span>
// <span className="text-green-600">
// 完成: {stats.completed}
// </span>
// <span className="text-red-600">失败: {stats.error}</span>
// <span className="text-orange-600">
// 取消: {stats.cancelled}
// </span>
// </div>
// </div>
)}
<Badge className="text-xs">
{t("Limit")}:{" "}
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
precision: 0,
})}{" "}
/{" "}
{formatFileSize(Number(plan?.stMaxTotalSize || "0"), {
precision: 0,
})}
</Badge>
</div>
<div className="space-y-3 p-4">
<DragAndDrop
setSelectedFile={setSelectedFile}
bucketInfo={bucketInfo}
/>
{/* 文件列表 */}
{files.length > 0 && (
<div className="space-y-2 rounded-lg">
{files.some((file) => file.status === "uploading") && (
<div className="flex items-center gap-1 rounded-md border border-dashed bg-yellow-100 p-2 text-sm text-muted-foreground dark:bg-neutral-600">
<Icons.info className="size-4" />
{t(
"Do not close the window until the upload is complete",
)}
.
</div>
)}
{/* 统计信息 */}
{stats.total > 0 && (
<div className="flex items-center justify-between gap-3">
<h2 className="font-semibold">{t("Upload List")}</h2>
<Badge className="flex items-center gap-1">
{stats.completed}
<span>/</span>
{stats.total}
</Badge>
</div>
// <div className="mt-6 rounded-lg bg-gray-50 p-4">
// <div className="flex flex-wrap gap-4 text-sm">
// <span className="text-gray-600">总计: {stats.total}</span>
// <span className="text-gray-600">等待: {stats.pending}</span>
// <span className="text-blue-600">
// 上传中: {stats.uploading}
// </span>
// <span className="text-green-600">
// 完成: {stats.completed}
// </span>
// <span className="text-red-600">失败: {stats.error}</span>
// <span className="text-orange-600">
// 取消: {stats.cancelled}
// </span>
// </div>
// </div>
)}
{/* 文件列表 */}
{files.map((file) => (
<div
key={file.id}
className={cn(
"relative overflow-hidden rounded-lg border",
file.status === "uploading" &&
"border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800",
file.status === "completed" &&
"border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20",
file.status === "error" &&
"border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20",
"backdrop-blur-sm transition-all duration-300",
file.status === "cancelled" &&
"border-yellow-300 bg-yellow-50 dark:border-yellow-600 dark:bg-yellow-900/20",
)}
>
{/* 主进度条背景 */}
{file.status === "uploading" && (
<div className="absolute inset-0 overflow-hidden rounded-lg">
<div
className="h-full bg-gray-200 transition-all duration-500 ease-out dark:bg-gray-700"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
{/* 文件列表 */}
{files.length > 0 && (
<div className="space-y-2 rounded-lg">
{files.some((file) => file.status === "uploading") && (
<div className="flex items-center gap-1 rounded-md border border-dashed bg-yellow-100 p-2 text-sm text-muted-foreground dark:bg-neutral-600">
<Icons.info className="size-4" />
{t(
"Do not close the window until the upload is complete",
)}
.
</div>
)}
{/* 内容区域 */}
<div className="relative z-10 px-4 py-3">
<div
className={cn(
"flex justify-between gap-3",
file.status === "uploading"
? "items-start"
: "items-center",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{file.originalName}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.file.size)}
</p>
{/* 文件列表 */}
{files.map((file) => (
<div
key={file.id}
className={cn(
"relative overflow-hidden rounded-lg border",
file.status === "uploading" &&
"border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800",
file.status === "completed" &&
"border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20",
file.status === "error" &&
"border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20",
"backdrop-blur-sm transition-all duration-300",
file.status === "cancelled" &&
"border-yellow-300 bg-yellow-50 dark:border-yellow-600 dark:bg-yellow-900/20",
)}
>
{/* 主进度条背景 */}
{file.status === "uploading" && (
<div className="absolute inset-0 overflow-hidden rounded-lg">
<div
className="h-full bg-gray-200 transition-all duration-500 ease-out dark:bg-gray-700"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
{/* 状态指示器和操作按钮 */}
<div className="flex items-center gap-2">
{file.status === "pending" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-neutral-600 px-3 py-1 text-xs text-white dark:bg-neutral-700">
<div className="h-2 w-2 rounded-full bg-neutral-300 dark:bg-neutral-400"></div>
{t("Pending Upload")}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeFile(file.id)}
className="size-[30px] p-1.5 text-red-600 transition-colors hover:text-red-800"
>
<X className="size-4" />
</Button>
</div>
) : file.status === "uploading" ? (
<div className="flex items-center gap-2">
<span className="text-sm">
{file.progress}%
</span>
<div className="flex items-center gap-1 rounded-full bg-gray-700 px-3 py-1 text-xs text-white dark:bg-gray-600">
<div className="h-2 w-2 animate-pulse rounded-full bg-gray-300 dark:bg-gray-400"></div>
{t("Uploading")}
</div>
<Button
className="size-6"
size="icon"
variant="destructive"
onClick={() => cancelUpload(file.id)}
title="取消上传"
>
<Icons.close className="size-4" />
</Button>
</div>
) : file.status === "completed" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-green-600 px-3 py-1 text-xs text-white dark:bg-green-700">
<div className="h-2 w-2 rounded-full bg-green-300 dark:bg-green-400"></div>
{t("Completed")}
</div>
<CopyButton
value={`${bucketInfo.custom_domain}/${file.fileName}`}
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeFile(file.id)}
className="size-[30px] p-1.5 text-red-600 transition-colors hover:text-red-800"
>
<X className="size-4" />
</Button>
</div>
) : (
(file.status === "error" ||
file.status === "cancelled") && (
{/* 内容区域 */}
<div className="relative z-10 px-4 py-3">
<div
className={cn(
"flex justify-between gap-3",
file.status === "uploading"
? "items-start"
: "items-center",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{file.originalName}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.file.size)}
</p>
</div>
{/* 状态指示器和操作按钮 */}
<div className="flex items-center gap-2">
{file.status === "pending" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
{t("Aborted")}
<div className="flex items-center gap-1 rounded-full bg-neutral-600 px-3 py-1 text-xs text-white dark:bg-neutral-700">
<div className="h-2 w-2 rounded-full bg-neutral-300 dark:bg-neutral-400"></div>
{t("Pending Upload")}
</div>
<Button
className="size-6"
size="icon"
variant="secondary"
onClick={() => retryUpload(file.id)}
>
<RotateCcw className="size-4" />
</Button>
<Button
className="size-6"
size="icon"
variant="destructive"
size="sm"
variant="ghost"
onClick={() => removeFile(file.id)}
className="size-[30px] p-1.5 text-red-600 transition-colors hover:text-red-800"
>
<X className="size-4" />
</Button>
</div>
)
)}
</div>
</div>
{/* 进度条 */}
{file.status === "uploading" && (
<div className="mt-3">
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="absolute left-0 top-0 h-full bg-gray-800 transition-all duration-300 ease-out dark:bg-gray-300"
style={{ width: `${file.progress}%` }}
/>
<div
className="absolute top-0 h-full w-16 bg-gradient-to-r from-transparent via-white/30 to-transparent transition-all duration-1000 ease-out dark:via-white/20"
style={{
left: `${Math.max(0, file.progress - 8)}%`,
opacity:
file.progress > 0 && file.progress < 100
? 1
: 0,
}}
/>
) : file.status === "uploading" ? (
<div className="flex items-center gap-2">
<span className="text-sm">
{file.progress}%
</span>
<div className="flex items-center gap-1 rounded-full bg-gray-700 px-3 py-1 text-xs text-white dark:bg-gray-600">
<div className="h-2 w-2 animate-pulse rounded-full bg-gray-300 dark:bg-gray-400"></div>
{t("Uploading")}
</div>
<Button
className="size-6"
size="icon"
variant="destructive"
onClick={() => cancelUpload(file.id)}
title="取消上传"
>
<Icons.close className="size-4" />
</Button>
</div>
) : file.status === "completed" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-green-600 px-3 py-1 text-xs text-white dark:bg-green-700">
<div className="h-2 w-2 rounded-full bg-green-300 dark:bg-green-400"></div>
{t("Completed")}
</div>
<CopyButton
value={`${bucketInfo.custom_domain}/${file.fileName}`}
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeFile(file.id)}
className="size-[30px] p-1.5 text-red-600 transition-colors hover:text-red-800"
>
<X className="size-4" />
</Button>
</div>
) : (
(file.status === "error" ||
file.status === "cancelled") && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
{t("Aborted")}
</div>
<Button
className="size-6"
size="icon"
variant="secondary"
onClick={() => retryUpload(file.id)}
>
<RotateCcw className="size-4" />
</Button>
<Button
className="size-6"
size="icon"
variant="destructive"
onClick={() => removeFile(file.id)}
>
<X className="size-4" />
</Button>
</div>
)
)}
</div>
</div>
)}
{/* 进度条 */}
{file.status === "uploading" && (
<div className="mt-3">
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="absolute left-0 top-0 h-full bg-gray-800 transition-all duration-300 ease-out dark:bg-gray-300"
style={{ width: `${file.progress}%` }}
/>
<div
className="absolute top-0 h-full w-16 bg-gradient-to-r from-transparent via-white/30 to-transparent transition-all duration-1000 ease-out dark:via-white/20"
style={{
left: `${Math.max(0, file.progress - 8)}%`,
opacity:
file.progress > 0 && file.progress < 100
? 1
: 0,
}}
/>
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
<DrawerFooter className="sticky bottom-0 flex flex-row items-center justify-between gap-2 backdrop-blur-md">
<DrawerClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DrawerClose>
<DrawerFooter className="sticky bottom-0 flex flex-row items-center justify-between gap-2 backdrop-blur-md">
<DrawerClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DrawerClose>
{files.length > 0 && (
<div className="flex items-center gap-2">
<Button
onClick={startUpload}
disabled={isUploading || stats.pending === 0}
className="flex items-center gap-2 rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<Play className="h-4 w-4" />
{t("Start Upload")}
</Button>
<Button
variant="destructive"
onClick={() => {
clearAll();
setSelectedFile(null);
}}
>
{t("Clear")}
</Button>
</div>
)}
</DrawerFooter>
</DrawerContent>
{files.length > 0 && (
<div className="flex items-center gap-2">
<Button
onClick={startUpload}
disabled={isUploading || stats.pending === 0}
className="flex items-center gap-2 rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<Play className="h-4 w-4" />
{t("Start Upload")}
</Button>
<Button
variant="destructive"
onClick={() => {
clearAll();
setSelectedFile(null);
}}
>
{t("Clear")}
</Button>
</div>
)}
</DrawerFooter>
</DrawerContent>
</DrawerPortal>
</Drawer>
)}
</>
+2 -1
View File
@@ -373,7 +373,8 @@
"Do not close the window until the upload is complete": "Do not close the window until the upload is complete",
"Pending Upload": "Pending Upload",
"Start Upload": "Start Upload",
"Limit": "Limit"
"Limit": "Limit",
"Files pasted successfully": "Files pasted successfully"
},
"Landing": {
"settings": "Settings",
+2 -1
View File
@@ -373,7 +373,8 @@
"Do not close the window until the upload is complete": "在上传完成之前不要浏览器关闭窗口",
"Pending Upload": "等待上传",
"Start Upload": "开始上传",
"Limit": "限制"
"Limit": "限制",
"Files pasted successfully": "文件粘贴成功"
},
"Landing": {
"settings": "设置",
+1 -1
View File
File diff suppressed because one or more lines are too long