feat: add file list display

This commit is contained in:
oiov
2025-07-04 16:44:05 +08:00
parent f90ccca8ba
commit f21bb49b49
25 changed files with 1314 additions and 347 deletions
+12 -4
View File
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import FileManager from "@/components/shared/file-manager";
import UserFileList from "@/components/file/file-list";
export const metadata = constructMetadata({
title: "Cloud Storage",
@@ -19,12 +19,20 @@ export default async function DashboardPage() {
<>
<DashboardHeader
heading="Cloud Storage"
text="List and manage cloud storage."
text="List and manage cloud storage"
link="/docs/cloud-storage"
linkText="Cloud Storage"
/>
<FileManager />
<UserFileList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
}}
action="/api/storage"
/>
</>
);
}
+1
View File
@@ -26,6 +26,7 @@ export async function GET(req: Request) {
return Response.json(data);
} catch (error) {
console.error("[Error]", error);
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
-54
View File
@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client } from "@aws-sdk/client-s3";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { createS3Client, getSignedUrlForUpload } from "@/lib/r2";
export async function POST(request: NextRequest) {
try {
const { fileName, fileType, bucket } = await request.json();
if (!fileName || !fileType || !bucket) {
return NextResponse.json("fileName, fileType and bucket is required", {
status: 400,
});
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.bucket.split(",");
if (!buckets.includes(bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const signedUrl = await getSignedUrlForUpload(
fileName,
fileType,
createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
),
bucket,
);
return NextResponse.json({ signedUrl });
} catch (error) {
return NextResponse.json(
{ error: "Error generating signed URL" },
{ status: 500 },
);
}
}
+189
View File
@@ -0,0 +1,189 @@
import { NextResponse } from "next/server";
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { createS3Client } from "@/lib/r2";
import { getCurrentUser } from "@/lib/session";
export async function POST(request: Request): Promise<Response> {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const formData = await request.formData();
const endpoint = formData.get("endPoint");
const bucket = formData.get("bucket");
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.bucket.split(",");
if (!buckets.includes(bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
);
switch (endpoint) {
case "create-multipart-upload":
return createMultipartUpload(formData, R2);
case "complete-multipart-upload":
return completeMultipartUpload(formData, R2);
case "abort-multipart-upload":
return abortMultipartUpload(formData, R2);
case "upload-part":
return uploadPart(formData, R2);
default:
return new Response(JSON.stringify({ error: "Endpoint not found" }), {
status: 404,
});
}
}
// Initiates a multipart upload
async function createMultipartUpload(
formData: FormData,
R2: S3Client,
): Promise<Response> {
const fileName = formData.get("fileName") as string;
const fileType = formData.get("fileType") as string;
const bucket = formData.get("bucket") as string;
try {
const params = {
Bucket: bucket,
Key: fileName,
ContentType: fileType,
};
const command = new CreateMultipartUploadCommand({ ...params });
const response = await R2.send(command);
return new Response(
JSON.stringify({
uploadId: response.UploadId,
key: response.Key,
}),
{ status: 200 },
);
} catch (err) {
console.log("Error From Create Multipart Upload => ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
// Completes a multipart upload
async function completeMultipartUpload(
formData: FormData,
R2: S3Client,
): Promise<Response> {
const key = formData.get("key") as string;
const uploadId = formData.get("uploadId") as string;
const bucket = formData.get("bucket") as string;
const parts = JSON.parse(formData.get("parts") as string);
try {
const params = {
Bucket: bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: { Parts: parts },
};
const command = new CompleteMultipartUploadCommand({ ...params });
const response = await R2.send(command);
return new Response(JSON.stringify(response), { status: 200 });
} catch (err) {
console.log("Error", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
// Aborts a multipart upload
async function abortMultipartUpload(
formData: FormData,
R2: S3Client,
): Promise<Response> {
const key = formData.get("key") as string;
const bucket = formData.get("bucket") as string;
const uploadId = formData.get("uploadId") as string;
try {
const params = {
Bucket: bucket,
Key: key,
UploadId: uploadId,
};
const command = new AbortMultipartUploadCommand({ ...params });
const response = await R2.send(command);
return new Response(JSON.stringify(response), { status: 200 });
} catch (err) {
console.log("Error", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
// Uploads a part of a file
async function uploadPart(formData: FormData, R2: S3Client): Promise<Response> {
const key = formData.get("key") as string;
const bucket = formData.get("bucket") as string;
const uploadId = formData.get("uploadId") as string;
const partNumber = Number(formData.get("partNumber")) as number;
const chunk = formData.get("chunk") as File;
try {
const arrayBuffer = await chunk.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const params = {
Bucket: bucket,
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
Body: buffer,
};
const command = new UploadPartCommand({ ...params });
const response = await R2.send(command);
return new Response(JSON.stringify({ etag: response.ETag }), {
status: 200,
});
} catch (err) {
console.log("Error From Uploadpart => ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ export function SendEmailModal({
</Button>
)}
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerContent className="fixed bottom-0 right-0 top-0 w-full rounded-none sm:max-w-xl">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-1">
+59
View File
@@ -0,0 +1,59 @@
"use client";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone";
import { BucketInfo } from "@/components/file/file-list";
import { Icons } from "../shared/icons";
import { Button } from "../ui/button";
const DragAndDrop = ({
setSelectedFile,
bucketInfo,
}: {
setSelectedFile: Dispatch<SetStateAction<File[] | null>>;
bucketInfo: BucketInfo;
}) => {
const t = useTranslations("Components");
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setSelectedFile((prev) => [...(prev ?? []), ...acceptedFiles]);
},
[setSelectedFile],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div
{...getRootProps()}
className={`grids flex h-52 w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-4 duration-150 ${
isDragActive
? "border-opacity-90 bg-muted/80 backdrop-blur-[2px]"
: "border-opacity-50 bg-muted/10 backdrop-blur-[1px]"
}`}
>
<input {...getInputProps()} />
<div className="text-center">
<div className="mx-auto w-fit transition-all duration-300">
<Icons.cloudUpload className="size-20" />
</div>
{isDragActive ? (
<div className="animate-fade-in text-primary">
{t("Drop files to upload them to")} {bucketInfo.bucket}
</div>
) : (
<div className="animate-fade-out">
<p>{t("Drag and drop file(s) here")}</p>
<p className="my-2 text-sm text-muted-foreground">{t("or")}</p>
<Button size="sm">{t("Browse file(s)")}</Button>
</div>
)}
</div>
</div>
);
};
export default DragAndDrop;
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { User } from "@prisma/client";
import useSWR from "swr";
import { ClientStorageCredentials } from "@/lib/r2";
import { fetcher } from "@/lib/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import FileManager from "@/components/file/file-manager";
import Uploader from "@/components/file/uploader";
import { Icons } from "@/components/shared/icons";
export interface FileListProps {
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role">;
action: string;
}
export type BucketInfo = {
bucket: string;
custom_domain?: string;
prefix?: string;
platform?: string;
channel?: string;
provider_name?: string;
};
export type DisplayType = "List" | "Grid";
export default function UserFileList({ user, action }: FileListProps) {
const [displayType, setDisplayType] = useState<DisplayType>("List");
const [bucketInfo, setBucketInfo] = useState<BucketInfo>({
bucket: "",
custom_domain: "",
prefix: "",
platform: "",
channel: "",
provider_name: "",
});
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
`${action}/r2/configs`,
fetcher,
{ revalidateOnFocus: false },
);
useEffect(() => {
if (r2Configs && r2Configs.buckets && r2Configs.buckets.length > 0) {
setBucketInfo({
bucket: r2Configs.buckets[0],
custom_domain: r2Configs.custom_domain?.[0],
prefix: r2Configs.prefix,
platform: r2Configs.platform,
channel: r2Configs.channel,
provider_name: r2Configs.provider_name,
});
}
}, [r2Configs]);
const handleChangeBucket = (bucket: string) => {
setBucketInfo({
...bucketInfo,
bucket,
});
};
return (
<>
<div>
<Tabs value={displayType}>
<div className="mb-4 flex items-center justify-between gap-3">
<TabsList className="mr-auto">
<TabsTrigger value="List" onClick={() => setDisplayType("List")}>
<Icons.list className="size-4" />
</TabsTrigger>
<TabsTrigger value="Grid" onClick={() => setDisplayType("Grid")}>
<Icons.layoutGrid className="size-4" />
</TabsTrigger>
</TabsList>
{isLoading ? (
<Skeleton className="h-9 w-[120px] rounded border-r-0 shadow-inner" />
) : (
<Select
value={bucketInfo.bucket}
onValueChange={handleChangeBucket}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Select a bucket" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="mx-auto text-center">
{r2Configs?.provider_name}
</SelectLabel>
{r2Configs?.buckets?.map((bucket) => (
<SelectItem
key={bucket}
value={bucket}
onClick={() => handleChangeBucket(bucket)}
>
{bucket}
</SelectItem>
))}
</SelectGroup>
{/* <SelectSeparator /> */}
</SelectContent>
</Select>
)}
<Uploader bucketInfo={bucketInfo} action={action} />
</div>
<FileManager
view={displayType}
bucketInfo={bucketInfo}
action={action}
/>
</Tabs>
</div>
</>
);
}
+325
View File
@@ -0,0 +1,325 @@
"use client";
import React, { useEffect, useState, useTransition } from "react";
import {
Archive,
Calendar,
Code2,
Download,
FileSpreadsheet,
FileText,
Folder,
HardDrive,
Image,
Presentation,
Trash2,
} from "lucide-react";
import { FileObject } from "@/lib/r2";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { BucketInfo, DisplayType } from "@/components/file/file-list";
import BlurImage from "../shared/blur-image";
import { Button } from "../ui/button";
// 文件类型图标映射
const getFileIcon = (filename: string, bucketInfo: BucketInfo) => {
const ext = filename.split(".").pop()?.toLowerCase();
const iconProps = { size: 24, className: "text-gray-600" };
switch (ext) {
case "jpg":
case "jpeg":
case "png":
case "gif":
case "webp":
return (
<BlurImage
className="rounded-md shadow"
height={60}
width={60}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${filename}`
: filename
}
alt={filename}
/>
);
case "svg":
return <Image {...iconProps} className="text-blue-500" />;
case "zip":
case "rar":
case "7z":
case "tar":
case "gz":
return <Archive {...iconProps} className="text-orange-500" />;
case "docx":
case "doc":
return <FileText {...iconProps} className="text-blue-600" />;
case "pptx":
case "ppt":
return <Presentation {...iconProps} className="text-red-500" />;
case "xlsx":
case "xls":
case "csv":
return <FileSpreadsheet {...iconProps} className="text-green-600" />;
case "json":
return <Code2 {...iconProps} className="text-yellow-600" />;
case "md":
case "markdown":
return <FileText {...iconProps} className="text-gray-700" />;
default:
// 检查是否是文件夹(没有扩展名且以/结尾)
if (!ext && filename.endsWith("/")) {
return <Folder {...iconProps} className="text-yellow-500" />;
}
return <FileText {...iconProps} className="text-gray-500" />;
}
};
// 格式化文件大小
const formatFileSize = (bytes?: number): string => {
if (!bytes) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unitIndex]}`;
};
// 格式化日期
const formatDate = (date?: Date): string => {
if (!date) return "-";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
};
export default function FileManager({
bucketInfo,
action,
view,
}: {
bucketInfo: BucketInfo;
action: string;
view: DisplayType;
}) {
const [files, setFiles] = useState<FileObject[]>([]);
const [isLoadingFiles, startLoadingFiles] = useTransition();
useEffect(() => {
if (bucketInfo.bucket) {
fetchFiles();
}
}, [bucketInfo.bucket]);
const fetchFiles = () => {
startLoadingFiles(async () => {
try {
const response = await fetch(
`${action}/r2/files?bucket=${bucketInfo.bucket}`,
);
const data = await response.json();
setFiles(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Error fetching files:", error);
setFiles([]);
}
});
};
const handleDownload = async (key: string) => {
try {
const response = await fetch(`${action}/r2/files`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: bucketInfo.bucket }),
});
const { signedUrl } = await response.json();
window.open(signedUrl, "_blank");
} catch (error) {
console.error("Error downloading file:", error);
alert("Error downloading file");
}
};
const handleDelete = async (key: string) => {
if (!confirm("确定要删除这个文件吗?")) return;
try {
await fetch(`${action}/r2/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: bucketInfo.bucket }),
});
alert("File deleted successfully!");
fetchFiles();
} catch (error) {
console.error("Error deleting file:", error);
alert("Error deleting file");
}
};
const renderListView = () => (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="grid grid-cols-12 gap-4 bg-gray-50 px-6 py-3 text-sm font-medium text-gray-700">
<div className="col-span-6"></div>
<div className="col-span-2"></div>
<div className="col-span-3"></div>
<div className="col-span-1"></div>
</div>
<div className="divide-y divide-gray-200">
{files.map((file) => (
<div
key={file.Key}
className="grid grid-cols-12 gap-4 px-6 py-4 transition-colors hover:bg-gray-50"
>
<div className="col-span-6 flex items-center space-x-3">
{getFileIcon(file.Key || "", bucketInfo)}
<span className="truncate text-gray-900">{file.Key}</span>
</div>
<div className="col-span-2 flex items-center text-sm text-gray-500">
<HardDrive size={16} className="mr-1" />
{formatFileSize(file.Size)}
</div>
<div className="col-span-3 flex items-center text-sm text-gray-500">
<Calendar size={16} className="mr-1" />
{formatDate(file.LastModified)}
</div>
<div className="col-span-1 flex items-center space-x-2">
<button
onClick={() => file.Key && handleDownload(file.Key)}
className="text-blue-500 transition-colors hover:text-blue-600"
title="下载"
>
<Download size={16} />
</button>
<button
onClick={() => file.Key && handleDelete(file.Key)}
className="text-red-500 transition-colors hover:text-red-600"
title="删除"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
</div>
);
const renderGridView = () => (
<div
className="grid justify-center justify-items-start gap-4"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(60px, 120px))",
}}
>
{files.map((file) => (
<div
key={file.Key}
className="group relative flex cursor-pointer items-end rounded-md transition-all hover:shadow"
>
<div className="flex flex-col items-center justify-center space-y-1">
{React.cloneElement(getFileIcon(file.Key || "", bucketInfo), {
size: 40,
})}
<div className="mt-0 w-full text-center">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger className="mx-auto max-w-[60px] truncate px-2 pb-1 text-xs font-medium text-primary sm:max-w-[100px]">
{file.Key}
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-[300px] space-y-1 p-3 text-start text-xs"
>
{["jpg", "jpeg", "png", "gif", "webp"].includes(
file.Key?.split(".").pop()?.toLowerCase() || "",
) && (
<BlurImage
className="rounded-md shadow"
width={300}
height={300}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${file.Key}`
: `${file.Key}`
}
alt={`${file.Key}`}
/>
)}
<p className="break-all">File Name: {file.Key}</p>
<p className="mt-1 text-xs text-gray-500">
Size: {formatFileSize(file.Size)}
</p>
<p className="mt-1 text-xs text-gray-400">
Modified: {formatDate(file.LastModified)}
</p>
<div className="flex space-x-1">
<Button
onClick={() => file.Key && handleDownload(file.Key)}
className="size-7"
title="下载"
size="icon"
variant={"blue"}
>
<Download size={14} />
</Button>
<Button
onClick={() => file.Key && handleDelete(file.Key)}
className="size-7"
title="删除"
size="icon"
variant={"destructive"}
>
<Trash2 size={14} />
</Button>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
))}
</div>
);
return (
<div className="w-full rounded-lg p-3">
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Folder size={48} className="mb-4 text-gray-400" />
{isLoadingFiles ? (
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
<span className="text-sm text-gray-500">...</span>
</div>
) : (
<p className="text-lg text-gray-500"></p>
)}
</div>
) : (
<>{view === "List" ? renderListView() : renderGridView()}</>
)}
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useTranslations } from "next-intl";
import { cn, formatFileSize } from "@/lib/utils";
import { BucketInfo } from "@/components/file/file-list";
import { CopyButton } from "../shared/copy-button";
import { Icons } from "../shared/icons";
import { Button } from "../ui/button";
import { UploadPendingItemType, UploadProgressType } from "./uploader";
const UploadPending = ({
pendingUpload,
progressList,
bucketInfo,
onAbort,
}: {
pendingUpload: UploadPendingItemType[] | null;
progressList: UploadProgressType[] | undefined;
bucketInfo: BucketInfo;
onAbort: (uploadId: string, key: string) => void;
}) => {
const t = useTranslations("Components");
return (
<div className="space-y-2 rounded-lg">
{pendingUpload && <h2 className="font-semibold">{t("Upload List")}</h2>}
{pendingUpload &&
pendingUpload.map((item) => {
const progress =
progressList?.find((p) => p.id === item.uploadId)?.progress || 0;
return (
<div
key={item.uploadId}
className={cn(
"relative overflow-hidden rounded-lg border",
item.status === "uploading" &&
"border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800",
item.status === "completed" &&
"border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20",
item.status === "aborted" &&
"border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20",
"backdrop-blur-sm transition-all duration-300",
)}
>
{/* 主进度条背景 */}
{item.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: `${progress}%` }}
/>
</div>
)}
{/* 内容区域 */}
<div className="relative z-10 px-4 py-3">
{/* 头部信息 */}
<div
className={cn(
"flex justify-between gap-3",
item.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">
{item.fileName}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(item.size)}
</p>
</div>
{/* 状态指示器 */}
<div className="flex items-center gap-2">
{item.status === "uploading" ? (
<div className="flex items-center gap-2">
<span className="text-sm">{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={() => onAbort(item.uploadId, item.key)}
>
<Icons.close className="size-4" />
</Button>
</div>
) : item.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
? `${bucketInfo.custom_domain}/${item.fileName}`
: item.path!
}
></CopyButton>
</div>
) : (
item.status === "aborted" && (
<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>
</div>
)
)}
</div>
</div>
{/* 进度条 */}
{item.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: `${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, progress - 8)}%`,
opacity: progress > 0 && progress < 100 ? 1 : 0,
}}
/>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export default UploadPending;
+301
View File
@@ -0,0 +1,301 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { BucketInfo } from "@/components/file/file-list";
import { Icons } from "../shared/icons";
import { Button } from "../ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "../ui/drawer";
import DragAndDrop from "./drag-and-drop";
import UploadPending from "./upload-pending";
export type UploadPendingItemType = {
uploadId: string;
fileName: string;
size: number;
status: "uploading" | "completed" | "aborted";
key: string;
path?: string;
};
export type UploadProgressType = {
id: string;
progress: number;
};
export default function Uploader({
bucketInfo,
action,
}: {
bucketInfo: BucketInfo;
action: string;
}) {
const t = useTranslations("Components");
const [isOpen, setIsOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File[] | null>(null);
const [pendingUpload, setPendingUpload] = useState<
UploadPendingItemType[] | null
>(null);
const [progressList, setProgressList] = useState<UploadProgressType[]>();
// Handles the upload process for selected files
const handleUpload = useCallback(() => {
selectedFile?.forEach((file: File) => {
uploadFile(file);
setSelectedFile((prev) => (prev ? prev.filter((f) => f !== file) : null));
});
}, [selectedFile]);
// Starts the multipart upload process
const startUpload = async (
file: File,
): Promise<{ uploadId: string; key: string }> => {
const formData = new FormData();
formData.append("fileName", file.name);
formData.append("fileType", file.type);
formData.append("bucket", bucketInfo.bucket);
formData.append("endPoint", "create-multipart-upload");
const response = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data; // { uploadId, key }
};
// Uploads file parts in chunks
const uploadParts = async (
file: File,
uploadId: string,
key: string,
onProgress: (progress: number) => void,
): Promise<{ ETag: string; PartNumber: number }[]> => {
const chunkSize = 5 * 1024 * 1024; // 5MB for each chunk
const totalChunks = Math.ceil(file.size / chunkSize);
const parts: { ETag: string; PartNumber: number }[] = [];
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
const partNumber = i + 1;
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("uploadId", uploadId);
formData.append("key", key);
formData.append("bucket", bucketInfo.bucket);
formData.append("partNumber", partNumber.toString());
formData.append("endPoint", "upload-part");
const uploadResponse = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const responseData = await uploadResponse.json();
if (responseData.error) {
throw new Error(responseData.error);
}
if (responseData.etag) {
parts.push({ ETag: responseData.etag, PartNumber: partNumber });
}
const progress = Math.round((partNumber / totalChunks) * 100);
onProgress(progress);
}
return parts;
};
// Completes the multipart upload process
const completeUpload = async (
uploadId: string,
key: string,
parts: { ETag: string; PartNumber: number }[],
): Promise<{ Location: string }> => {
const formData = new FormData();
formData.append("key", key);
formData.append("uploadId", uploadId);
formData.append("bucket", bucketInfo.bucket);
formData.append("parts", JSON.stringify(parts));
formData.append("endPoint", "complete-multipart-upload");
const response = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data;
};
const abortUpload = async (uploadId: string, key: string) => {
const formData = new FormData();
formData.append("uploadId", uploadId);
formData.append("key", key);
formData.append("bucket", bucketInfo.bucket);
formData.append("endPoint", "abort-multipart-upload");
const response = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setPendingUpload(
(prev) =>
prev?.map((item) =>
item.uploadId === uploadId
? { ...item, status: "aborted", path: "" }
: item,
) ?? [],
);
return data;
};
// Manages the entire file upload process
const uploadFile = async (file: File): Promise<void> => {
try {
const { uploadId, key } = await startUpload(file);
setProgressList((prev) => [
...(prev ?? []),
{ id: uploadId, progress: 0 },
]);
setPendingUpload((prev) => [
...(prev ?? []),
{
uploadId,
fileName: file.name,
size: file.size,
path: "",
status: "uploading",
key,
},
]);
const parts = await uploadParts(file, uploadId, key, (progress) => {
// console.log(`Upload Progress: ${progress}%`);
setProgressList(
(prev) =>
prev?.map((item) =>
item.id === uploadId ? { ...item, progress } : item,
) ?? [],
);
});
const result = await completeUpload(uploadId, key, parts);
setProgressList(
(prev) =>
prev?.map((item) =>
item.id === uploadId ? { ...item, progress: 100 } : item,
) ?? [],
);
setPendingUpload(
(prev) =>
prev?.map((item) =>
item.uploadId === uploadId
? { ...item, status: "completed", path: result.Location }
: item,
) ?? [],
);
} catch (error) {
console.error(error);
}
};
// Triggers the upload process when files are selected
useEffect(() => {
if (selectedFile?.length) {
handleUpload();
}
}, [selectedFile]);
return (
<>
{!isOpen && (
<Button
className="flex h-9 items-center gap-1"
onClick={() => setIsOpen(true)}
>
<Icons.cloudUpload className="size-5" />
{t("Upload Files")}
</Button>
)}
{isOpen && (
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerContent className="h-screen w-full overflow-y-auto 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="px-4">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<span>{t("Uploud channel")}: </span>
<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>
</DrawerDescription>
<div className="space-y-4 p-4">
<DragAndDrop
setSelectedFile={setSelectedFile}
bucketInfo={bucketInfo}
/>
<UploadPending
pendingUpload={pendingUpload}
progressList={progressList}
bucketInfo={bucketInfo}
onAbort={abortUpload}
/>
</div>
<DrawerFooter className="flex flex-row items-center justify-between gap-2">
<DrawerClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DrawerClose>
<Button
variant="destructive"
onClick={() => {
setSelectedFile([]);
setPendingUpload([]);
setProgressList([]);
}}
>
{t("Clear")}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
</>
);
}
+2 -2
View File
@@ -399,7 +399,7 @@ export function DomainForm({
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
{t("API Token")}:
{t("API Key")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -422,7 +422,7 @@ export function DomainForm({
href="/docs/developer/cloudflare"
target="_blank"
>
{t("How to get api token?")}
{t("How to get api key?")}
</Link>
</p>
)}
-255
View File
@@ -1,255 +0,0 @@
"use client";
import React, {
ChangeEvent,
FormEvent,
useEffect,
useRef,
useState,
} from "react";
import useSWR from "swr";
import { ClientStorageCredentials, FileObject } from "@/lib/r2";
import { fetcher } from "@/lib/utils";
export default function FileManager() {
const [files, setFiles] = useState<FileObject[]>([]);
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [currentBucket, setCurrentBucket] = useState<string>("");
const { data: configs, isLoading } = useSWR<ClientStorageCredentials>(
"/api/s3/r2/configs",
fetcher,
);
useEffect(() => {
if (configs && configs.buckets && configs.buckets.length > 0) {
setCurrentBucket(configs.buckets[0]);
}
if (currentBucket) {
fetchFiles();
}
}, [configs, currentBucket]);
const fetchFiles = async () => {
try {
const response = await fetch(`/api/s3/r2/files?bucket=${currentBucket}`);
const data = await response.json();
setFiles(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Error fetching files:", error);
setFiles([]);
}
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFile(e.target.files[0]);
}
};
const handleUpload = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!file) return;
setIsUploading(true);
setUploadProgress(0);
abortControllerRef.current = new AbortController();
try {
const response = await fetch("/api/s3/r2/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
fileType: file.type,
bucket: currentBucket,
}),
});
const { signedUrl } = await response.json();
await uploadFileWithProgress(
file,
signedUrl,
abortControllerRef.current.signal,
);
alert("File uploaded successfully!");
setFile(null); // Clear the file input
fetchFiles();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.log("Upload cancelled");
} else {
console.error("Error uploading file:", error);
alert("Error uploading file");
}
} finally {
setIsUploading(false);
setUploadProgress(0);
abortControllerRef.current = null;
}
};
const uploadFileWithProgress = (
file: File,
signedUrl: string,
signal: AbortSignal,
): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", signedUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setUploadProgress(percentComplete);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error("Upload failed"));
};
xhr.send(file);
signal.addEventListener("abort", () => {
xhr.abort();
reject(new Error("Upload cancelled"));
});
});
};
const handleCancelUpload = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
const handleDownload = async (key: string) => {
try {
const response = await fetch("/api/s3/r2/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: currentBucket }),
});
const { signedUrl } = await response.json();
window.open(signedUrl, "_blank");
} catch (error) {
console.error("Error downloading file:", error);
alert("Error downloading file");
}
};
const handleDelete = async (key: string) => {
try {
await fetch("/api/s3/r2/files", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: currentBucket }),
});
alert("File deleted successfully!");
fetchFiles();
} catch (error) {
console.error("Error deleting file:", error);
alert("Error deleting file");
}
};
return (
<div className="mx-auto mt-24 max-w-2xl rounded-lg bg-white p-6 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-semibold text-gray-600">
Cloudflare R2 with Next.js: Upload, Download, Delete
</h1>
<h2 className="mb-6 text-2xl font-semibold text-gray-800">Upload File</h2>
<form onSubmit={handleUpload} className="mb-8">
<div className="flex items-center space-x-4">
<label className="flex-1">
<input
type="file"
onChange={handleFileChange}
disabled={isUploading}
className="hidden"
id="file-upload"
/>
<div className="cursor-pointer rounded-lg border border-blue-300 bg-blue-50 px-4 py-2 text-blue-500 transition duration-300 hover:bg-blue-100">
{file ? file.name : "Choose a file"}
</div>
</label>
<button
type="submit"
disabled={!file || isUploading}
className="rounded-lg bg-blue-500 px-6 py-2 text-white transition duration-300 hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploading ? "Uploading..." : "Upload"}
</button>
</div>
</form>
{isUploading && (
<div className="mb-8">
<div className="mb-4 h-2.5 w-full rounded-full bg-gray-200">
<div
className="h-2.5 rounded-full bg-blue-600"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
{uploadProgress.toFixed(2)}% uploaded
</p>
<button
onClick={handleCancelUpload}
className="text-red-500 transition duration-300 hover:text-red-600"
>
Cancel Upload
</button>
</div>
</div>
)}
<h2 className="mb-4 text-2xl font-semibold text-gray-800">Files</h2>
{files.length === 0 ? (
<p className="italic text-gray-500">No files found.</p>
) : (
<ul className="space-y-4">
{files.map((file) => (
<li
key={file.Key}
className="flex items-center justify-between rounded-lg bg-gray-50 p-4"
>
<span className="flex-1 truncate text-gray-700">{file.Key}</span>
<div className="flex space-x-2">
<button
onClick={() => file.Key && handleDownload(file.Key)}
className="text-blue-500 transition duration-300 hover:text-blue-600"
>
Download
</button>
<button
onClick={() => file.Key && handleDelete(file.Key)}
className="text-red-500 transition duration-300 hover:text-red-600"
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
+41 -1
View File
@@ -88,7 +88,47 @@ export const Icons = {
camera: Camera,
calendar: Calendar,
crown: Crown,
cloudUpload: CloudUpload,
cloudUpload: ({ ...props }: LucideProps) => (
<svg
role="presentation"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
{...props}
>
<g clipPath="url(#upload_svg__clip0)">
<path
fill="currentColor"
d="M14.966 7.211a2.91 2.91 0 00-2-.68 4.822 4.822 0 00-9.243-1.147A3.41 3.41 0 001.3 6.18 3.65 3.65 0 000 8.938a3.562 3.562 0 003.554 3.555H6.5v-1H3.547A2.559 2.559 0 011 8.938a2.64 2.64 0 01.943-1.992 2.413 2.413 0 012.032-.527l.435.075.13-.422a3.821 3.821 0 017.47 1.016l.017.57.563-.091a2.071 2.071 0 011.729.404A2.029 2.029 0 0115 9.508a1.987 1.987 0 01-1.985 1.985h-.032c-.061.001-.428.006-2.483.006v1c1.93 0 2.392-.004 2.515-.007a3.01 3.01 0 001.951-5.282v.001z"
></path>
<path
fill="currentColor"
d="M10.95 9.456l-2.46-2.5-2.46 2.5.712.701L7.99 8.89v3.62h1v-3.62l1.248 1.268.713-.701z"
></path>
</g>
<defs>
<clipPath id="upload_svg__clip0">
<path d="M0 0h16v16H0z"></path>
</clipPath>
</defs>
</svg>
),
storage: ({ ...props }: LucideProps) => (
<svg
role="presentation"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
{...props}
>
<path
fillRule="evenodd"
d="M12.116 2.57c-.973-.326-2.384-.546-3.991-.546-1.607 0-3.018.22-3.99.545-.492.164-.814.337-.992.478a.89.89 0 00-.082.072c.02.069.078.158.225.268.21.158.548.313 1.025.448.947.266 2.292.409 3.814.409 1.522 0 2.867-.143 3.814-.41.477-.134.816-.29 1.025-.447.147-.11.204-.2.225-.268a.884.884 0 00-.082-.072c-.178-.141-.5-.314-.991-.478zM4.02 4.818a5.18 5.18 0 01-.97-.369v1.757c0 .079.039.206.25.377.214.173.556.347 1.03.5.944.306 2.284.49 3.795.49 1.51 0 2.85-.184 3.794-.49.474-.153.817-.327 1.03-.5.212-.171.251-.298.251-.377V4.45a5.18 5.18 0 01-.97.369c-1.08.304-2.534.45-4.105.45-1.571 0-3.026-.146-4.105-.45zm10.23 1.388V3.05C14.25 1.917 11.508 1 8.125 1S2 1.917 2 3.049V12.95C2 14.083 4.742 15 8.125 15s6.125-.917 6.125-2.049V6.207zM13.2 7.654c-.28.157-.602.29-.95.403-1.083.35-2.543.54-4.125.54-1.582 0-3.042-.19-4.125-.54a5.258 5.258 0 01-.95-.403v1.712c0 .078.039.205.25.376.214.173.556.348 1.03.501.944.306 2.284.489 3.795.489 1.51 0 2.85-.183 3.794-.489.474-.153.817-.328 1.03-.5.212-.172.251-.299.251-.377V7.654zM4 11.215a5.253 5.253 0 01-.95-.403v2.058c.018.019.047.046.093.083.178.141.5.314.992.478.972.325 2.383.545 3.99.545 1.607 0 3.018-.22 3.99-.545.492-.164.814-.337.992-.478a.809.809 0 00.093-.083v-2.058a5.25 5.25 0 01-.95.403c-1.083.351-2.543.541-4.125.541-1.582 0-3.042-.19-4.125-.54zm9.224 1.624s0 .002-.004.006a.028.028 0 01.004-.006zm-10.198 0l.004.006a.024.024 0 01-.004-.006zm1.599-6.29c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.513c-.29 0-.525.23-.525.513 0 .282.235.512.525.512zM5.15 9.28a.519.519 0 01-.525.513.519.519 0 01-.525-.513c0-.282.235-.512.525-.512.29 0 .525.23.525.512zm-.525 3.671c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.512c-.29 0-.525.23-.525.512 0 .283.235.512.525.512z"
></path>
</svg>
),
eye: Eye,
lock: LockKeyhole,
list: List,
+1 -1
View File
@@ -14,7 +14,7 @@ export const sidebarLinks: SidebarNavItem[] = [
{ href: "/emails", icon: "mail", title: "Emails" },
{
href: "/dashboard/storage",
icon: "cloudUpload",
icon: "storage",
title: "Cloud Storage",
},
],
+6 -6
View File
@@ -18,7 +18,7 @@ The `Short URL Service` and `Email Service` require no additional configuration
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
- Zone ID
- API Token
- API Key
- Email
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
@@ -29,9 +29,9 @@ The unique identifier for a domain hosted on Cloudflare, located at:
https://dash.cloudflare.com/[account_id]/[zone_name]
### API Token
### API Key
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
Visit https://dash.cloudflare.com/profile/api-tokens, and find the **Global API** Key under the API Tokens section.
### Email
@@ -39,7 +39,7 @@ Email for registering a Cloudflare account
<Callout type="info">
You can manage domains hosted under different Cloudflare accounts,
provided the API Token and Email are sourced from the same account.
provided the API Key and Email are sourced from the same account.
</Callout>
---
@@ -72,11 +72,11 @@ NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
### CLOUDFLARE_API_KEY
- Description: The API key used to authenticate requests to the Cloudflare API.
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your Global API Key.
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your **Global API Key**.
- Example: 1234567890abcdef1234567890abcdef
- Security Note: Keep this key confidential and never expose it in client-side code.
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the **Global API Key** under the API Tokens section.
### CLOUDFLARE_EMAIL
+4 -1
View File
@@ -200,10 +200,13 @@ export const updateUser = async (userId: string, data: UpdateUserForm) => {
export const deleteUserById = async (userId: string) => {
try {
const session = await prisma.user.delete({
const session = await prisma.user.update({
where: {
id: userId,
},
data: {
active: 0,
},
});
return session;
} catch (error) {
+4 -2
View File
@@ -1,14 +1,16 @@
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getMultipleConfigs } from "./dto/system-config";
export interface CloudStorageCredentials {
enabled?: boolean;
platform?: string;
+6
View File
@@ -397,3 +397,9 @@ export function verifyPassword(
const hashToVerify = crypto.scryptSync(password, salt, 64).toString("hex");
return hash === hashToVerify;
}
export const formatFileSizeX = (bytes: number) => {
if (bytes < 1048576) return (bytes / 1024).toFixed() + " KB";
if (bytes < 1073741824) return (bytes / 1048576).toFixed(2) + " MB";
return (bytes / 1073741824).toFixed(2) + " GB";
};
+16 -2
View File
@@ -130,7 +130,7 @@
"API Token": "API Token",
"Account Email": "Account Email",
"How to get zone id?": "How to get zone id?",
"How to get api token?": "How to get api token?",
"How to get api key?": "How to get api key?",
"How to get cloudflare account email?": "How to get cloudflare account email?",
"Resend Configs": "Resend Configs",
"Associate with 'Subdomain Service' status": "Associate with 'Subdomain Service' status",
@@ -324,7 +324,21 @@
"Actived": "Active",
"Disabled": "Disabled",
"Expired": "Expired",
"PasswordProtected": "Password Protected"
"PasswordProtected": "Password Protected",
"Upload List": "Upload List",
"Uploading": "Uploading",
"Completed": "Completed",
"Aborted": "Aborted",
"Drop files to upload them to": "Drop files to upload them to",
"Drag and drop file(s) here": "Drag and drop file(s) here",
"or": "or",
"Browse file(s)": "Browse file(s)",
"Cloud Storage": "Cloud Storage",
"List and manage cloud storage": "List and manage cloud storage",
"Cancel": "Cancel",
"Clear": "Clear",
"Upload Files": "Upload Files",
"Uploud channel": "Uploud channel"
},
"Landing": {
"settings": "Settings",
+16 -2
View File
@@ -130,7 +130,7 @@
"API Token": "API Token",
"Account Email": "账户邮箱",
"How to get zone id?": "如何获取 Zone ID?",
"How to get api token?": "如何获取 API Token?",
"How to get api key?": "如何获取 API 密钥?",
"How to get cloudflare account email?": "如何获取账户邮箱?",
"Resend Configs": "Resend 配置",
"Associate with 'Subdomain Service' status": "与 '子域名服务' 启用状态关联",
@@ -324,7 +324,21 @@
"Actived": "有效",
"Disabled": "已禁用",
"Expired": "已过期",
"PasswordProtected": "密码保护"
"PasswordProtected": "密码保护",
"Upload List": "上传列表",
"Uploading": "上传中",
"Completed": "已完成",
"Aborted": "已中止",
"Drop files to upload them to": "将文件上传到",
"Drag and drop file(s) here": "将文件拖到此处上传",
"or": "或",
"Browse file(s)": "浏览本地文件",
"Cloud Storage": "云存储",
"List and manage cloud storage": "上传和管理云存储文件",
"Cancel": "取消",
"Clear": "清空",
"Upload Files": "上传文件",
"Uploud channel": "渠道"
},
"Landing": {
"settings": "设置",
+1
View File
@@ -115,6 +115,7 @@
"react-countup": "^6.5.3",
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-dropzone": "^14.3.8",
"react-email": "2.1.5",
"react-globe.gl": "^2.33.2",
"react-hook-form": "^7.52.1",
+45 -15
View File
@@ -278,6 +278,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-dropzone:
specifier: ^14.3.8
version: 14.3.8(react@18.3.1)
react-email:
specifier: 2.1.5
version: 2.1.5(@opentelemetry/api@1.8.0)(@swc/helpers@0.5.5)(eslint@8.57.0)
@@ -4120,6 +4123,10 @@ packages:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'}
attr-accept@2.2.5:
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
engines: {node: '>=4'}
autoprefixer@10.4.14:
resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -5263,6 +5270,10 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
file-selector@2.1.2:
resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
engines: {node: '>= 12'}
filelist@1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
@@ -7088,6 +7099,12 @@ packages:
peerDependencies:
react: ^18.3.1
react-dropzone@14.3.8:
resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==}
engines: {node: '>= 10.13'}
peerDependencies:
react: '>= 16.8 || 18.0.0'
react-email@2.1.5:
resolution: {integrity: sha512-SjGt5XiqNwrC6FT0rAxERj0MC9binUOVZDzspAxcRHpxjZavvePAHvV29uROWNQ1Ha7ssg1sfy4dTQi7bjCXrg==}
engines: {node: '>=18.0.0'}
@@ -13057,6 +13074,8 @@ snapshots:
at-least-node@1.0.0: {}
attr-accept@2.2.5: {}
autoprefixer@10.4.14(postcss@8.4.38):
dependencies:
browserslist: 4.23.0
@@ -14104,7 +14123,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.35.0(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@@ -14140,8 +14159,8 @@ snapshots:
debug: 4.3.4
enhanced-resolve: 5.15.0
eslint: 8.57.0
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.2
is-core-module: 2.13.1
@@ -14152,7 +14171,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-module-utils@2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -14163,7 +14182,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.3
@@ -14173,7 +14192,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.16.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
@@ -14435,6 +14454,10 @@ snapshots:
dependencies:
flat-cache: 3.2.0
file-selector@2.1.2:
dependencies:
tslib: 2.8.1
filelist@1.0.4:
dependencies:
minimatch: 5.1.6
@@ -14500,7 +14523,7 @@ snapshots:
framer-motion@10.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 0.8.8
react: 18.3.1
@@ -16592,6 +16615,13 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-dropzone@14.3.8(react@18.3.1):
dependencies:
attr-accept: 2.2.5
file-selector: 2.1.2
prop-types: 15.8.1
react: 18.3.1
react-email@2.1.5(@opentelemetry/api@1.8.0)(@swc/helpers@0.5.5)(eslint@8.57.0):
dependencies:
'@babel/core': 7.24.5
@@ -16681,7 +16711,7 @@ snapshots:
dependencies:
react: 18.3.1
react-style-singleton: 2.2.1(@types/react@18.2.47)(react@18.3.1)
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.47
@@ -16689,7 +16719,7 @@ snapshots:
dependencies:
react: 18.3.1
react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.3.1)
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.3
@@ -16739,7 +16769,7 @@ snapshots:
get-nonce: 1.0.1
invariant: 2.2.4
react: 18.3.1
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.47
@@ -16748,7 +16778,7 @@ snapshots:
get-nonce: 1.0.1
invariant: 2.2.4
react: 18.3.1
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.3
@@ -17844,14 +17874,14 @@ snapshots:
use-callback-ref@1.3.0(@types/react@18.2.47)(react@18.3.1):
dependencies:
react: 18.3.1
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.47
use-callback-ref@1.3.0(@types/react@18.3.3)(react@18.3.1):
dependencies:
react: 18.3.1
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.3
@@ -17883,7 +17913,7 @@ snapshots:
dependencies:
detect-node-es: 1.1.0
react: 18.3.1
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.2.47
@@ -17891,7 +17921,7 @@ snapshots:
dependencies:
detect-node-es: 1.1.0
react: 18.3.1
tslib: 2.6.2
tslib: 2.8.1
optionalDependencies:
'@types/react': 18.3.3
+1 -1
View File
File diff suppressed because one or more lines are too long