feat: add file list display
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -14,7 +14,7 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
{ href: "/emails", icon: "mail", title: "Emails" },
|
||||
{
|
||||
href: "/dashboard/storage",
|
||||
icon: "cloudUpload",
|
||||
icon: "storage",
|
||||
title: "Cloud Storage",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "设置",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+45
-15
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user