From f21bb49b4940a1507fcda303895f22be696df049 Mon Sep 17 00:00:00 2001 From: oiov Date: Fri, 4 Jul 2025 16:44:05 +0800 Subject: [PATCH] feat: add file list display --- app/(protected)/dashboard/storage/page.tsx | 16 +- app/api/record/admin/route.ts | 1 + app/api/s3/r2/upload/route.ts | 54 ---- app/api/{s3 => storage}/r2/configs/route.ts | 0 app/api/{s3 => storage}/r2/files/route.ts | 0 app/api/storage/r2/uploads/route.ts | 189 ++++++++++++ components/email/SendEmailModal.tsx | 2 +- components/file/drag-and-drop.tsx | 59 ++++ components/file/file-list.tsx | 134 ++++++++ components/file/file-manager.tsx | 325 ++++++++++++++++++++ components/file/upload-pending.tsx | 149 +++++++++ components/file/uploader.tsx | 301 ++++++++++++++++++ components/forms/domain-form.tsx | 4 +- components/shared/file-manager.tsx | 255 --------------- components/shared/icons.tsx | 42 ++- config/dashboard.ts | 2 +- content/docs/developer/cloudflare.mdx | 12 +- lib/dto/user.ts | 5 +- lib/r2.ts | 6 +- lib/utils.ts | 6 + locales/en.json | 18 +- locales/zh.json | 18 +- package.json | 1 + pnpm-lock.yaml | 60 +++- public/sw.js.map | 2 +- 25 files changed, 1314 insertions(+), 347 deletions(-) delete mode 100644 app/api/s3/r2/upload/route.ts rename app/api/{s3 => storage}/r2/configs/route.ts (100%) rename app/api/{s3 => storage}/r2/files/route.ts (100%) create mode 100644 app/api/storage/r2/uploads/route.ts create mode 100644 components/file/drag-and-drop.tsx create mode 100644 components/file/file-list.tsx create mode 100644 components/file/file-manager.tsx create mode 100644 components/file/upload-pending.tsx create mode 100644 components/file/uploader.tsx delete mode 100644 components/shared/file-manager.tsx diff --git a/app/(protected)/dashboard/storage/page.tsx b/app/(protected)/dashboard/storage/page.tsx index 9a38a9d..4226f63 100644 --- a/app/(protected)/dashboard/storage/page.tsx +++ b/app/(protected)/dashboard/storage/page.tsx @@ -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() { <> - - + ); } diff --git a/app/api/record/admin/route.ts b/app/api/record/admin/route.ts index 082ddac..a8eff26 100644 --- a/app/api/record/admin/route.ts +++ b/app/api/record/admin/route.ts @@ -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, }); diff --git a/app/api/s3/r2/upload/route.ts b/app/api/s3/r2/upload/route.ts deleted file mode 100644 index d82aa2a..0000000 --- a/app/api/s3/r2/upload/route.ts +++ /dev/null @@ -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 }, - ); - } -} diff --git a/app/api/s3/r2/configs/route.ts b/app/api/storage/r2/configs/route.ts similarity index 100% rename from app/api/s3/r2/configs/route.ts rename to app/api/storage/r2/configs/route.ts diff --git a/app/api/s3/r2/files/route.ts b/app/api/storage/r2/files/route.ts similarity index 100% rename from app/api/s3/r2/files/route.ts rename to app/api/storage/r2/files/route.ts diff --git a/app/api/storage/r2/uploads/route.ts b/app/api/storage/r2/uploads/route.ts new file mode 100644 index 0000000..f56aced --- /dev/null +++ b/app/api/storage/r2/uploads/route.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }); + } +} diff --git a/components/email/SendEmailModal.tsx b/components/email/SendEmailModal.tsx index 831251e..ab0fdc4 100644 --- a/components/email/SendEmailModal.tsx +++ b/components/email/SendEmailModal.tsx @@ -94,7 +94,7 @@ export function SendEmailModal({ )} - + diff --git a/components/file/drag-and-drop.tsx b/components/file/drag-and-drop.tsx new file mode 100644 index 0000000..2482d8e --- /dev/null +++ b/components/file/drag-and-drop.tsx @@ -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>; + bucketInfo: BucketInfo; +}) => { + const t = useTranslations("Components"); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + setSelectedFile((prev) => [...(prev ?? []), ...acceptedFiles]); + }, + [setSelectedFile], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); + + return ( +
+ +
+
+ +
+ {isDragActive ? ( +
+ {t("Drop files to upload them to")} {bucketInfo.bucket} +
+ ) : ( +
+

{t("Drag and drop file(s) here")}

+

{t("or")}

+ +
+ )} +
+
+ ); +}; +export default DragAndDrop; diff --git a/components/file/file-list.tsx b/components/file/file-list.tsx new file mode 100644 index 0000000..1e39c7c --- /dev/null +++ b/components/file/file-list.tsx @@ -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; + 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("List"); + const [bucketInfo, setBucketInfo] = useState({ + bucket: "", + custom_domain: "", + prefix: "", + platform: "", + channel: "", + provider_name: "", + }); + + const { data: r2Configs, isLoading } = useSWR( + `${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 ( + <> +
+ +
+ + setDisplayType("List")}> + + + setDisplayType("Grid")}> + + + + + {isLoading ? ( + + ) : ( + + )} + + +
+ + +
+
+ + ); +} diff --git a/components/file/file-manager.tsx b/components/file/file-manager.tsx new file mode 100644 index 0000000..a6812b9 --- /dev/null +++ b/components/file/file-manager.tsx @@ -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 ( + + ); + case "svg": + return ; + case "zip": + case "rar": + case "7z": + case "tar": + case "gz": + return ; + case "docx": + case "doc": + return ; + case "pptx": + case "ppt": + return ; + case "xlsx": + case "xls": + case "csv": + return ; + case "json": + return ; + case "md": + case "markdown": + return ; + default: + // 检查是否是文件夹(没有扩展名且以/结尾) + if (!ext && filename.endsWith("/")) { + return ; + } + return ; + } +}; + +// 格式化文件大小 +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([]); + 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 = () => ( +
+
+
名称
+
大小
+
修改时间
+
操作
+
+
+ {files.map((file) => ( +
+
+ {getFileIcon(file.Key || "", bucketInfo)} + {file.Key} +
+
+ + {formatFileSize(file.Size)} +
+
+ + {formatDate(file.LastModified)} +
+
+ + +
+
+ ))} +
+
+ ); + + const renderGridView = () => ( +
+ {files.map((file) => ( +
+
+ {React.cloneElement(getFileIcon(file.Key || "", bucketInfo), { + size: 40, + })} +
+ + + + {file.Key} + + + {["jpg", "jpeg", "png", "gif", "webp"].includes( + file.Key?.split(".").pop()?.toLowerCase() || "", + ) && ( + + )} +

File Name: {file.Key}

+

+ Size: {formatFileSize(file.Size)} +

+

+ Modified: {formatDate(file.LastModified)} +

+
+ + +
+
+
+
+
+
+
+ ))} +
+ ); + + return ( +
+ {files.length === 0 ? ( +
+ + + {isLoadingFiles ? ( +
+
+ 加载中... +
+ ) : ( +

暂无文件

+ )} +
+ ) : ( + <>{view === "List" ? renderListView() : renderGridView()} + )} +
+ ); +} diff --git a/components/file/upload-pending.tsx b/components/file/upload-pending.tsx new file mode 100644 index 0000000..e2a798b --- /dev/null +++ b/components/file/upload-pending.tsx @@ -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 ( +
+ {pendingUpload &&

{t("Upload List")}

} + {pendingUpload && + pendingUpload.map((item) => { + const progress = + progressList?.find((p) => p.id === item.uploadId)?.progress || 0; + return ( +
+ {/* 主进度条背景 */} + {item.status === "uploading" && ( +
+
+
+ )} + + {/* 内容区域 */} +
+ {/* 头部信息 */} +
+
+

+ {item.fileName} +

+

+ {formatFileSize(item.size)} +

+
+ + {/* 状态指示器 */} +
+ {item.status === "uploading" ? ( +
+ {progress}% +
+
+ {t("Uploading")} +
+ +
+ ) : item.status === "completed" ? ( +
+
+
+ {t("Completed")} +
+ +
+ ) : ( + item.status === "aborted" && ( +
+
+
+ {t("Aborted")} +
+
+ ) + )} +
+
+ + {/* 进度条 */} + {item.status === "uploading" && ( +
+
+ {/* 进度填充 */} +
+ {/* 动态光泽效果 */} +
0 && progress < 100 ? 1 : 0, + }} + /> +
+
+ )} +
+
+ ); + })} +
+ ); +}; + +export default UploadPending; diff --git a/components/file/uploader.tsx b/components/file/uploader.tsx new file mode 100644 index 0000000..339f3cb --- /dev/null +++ b/components/file/uploader.tsx @@ -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(null); + const [pendingUpload, setPendingUpload] = useState< + UploadPendingItemType[] | null + >(null); + const [progressList, setProgressList] = useState(); + + // 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 => { + 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 && ( + + )} + {isOpen && ( + + + + + {t("Upload Files")} + + + + + + +
+ {t("Uploud channel")}: +
{bucketInfo.provider_name}
+ +
+ {bucketInfo.bucket} +
+
+
+ +
+ + +
+ + + + + + + +
+
+ )} + + ); +} diff --git a/components/forms/domain-form.tsx b/components/forms/domain-form.tsx index 1123cd4..d3314c3 100644 --- a/components/forms/domain-form.tsx +++ b/components/forms/domain-form.tsx @@ -399,7 +399,7 @@ export function DomainForm({
- {t("How to get api token?")} + {t("How to get api key?")}

)} diff --git a/components/shared/file-manager.tsx b/components/shared/file-manager.tsx deleted file mode 100644 index 09d5290..0000000 --- a/components/shared/file-manager.tsx +++ /dev/null @@ -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([]); - const [file, setFile] = useState(null); - const [uploadProgress, setUploadProgress] = useState(0); - const [isUploading, setIsUploading] = useState(false); - const abortControllerRef = useRef(null); - const [currentBucket, setCurrentBucket] = useState(""); - - const { data: configs, isLoading } = useSWR( - "/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) => { - if (e.target.files) { - setFile(e.target.files[0]); - } - }; - - const handleUpload = async (e: FormEvent) => { - 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 => { - 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 ( -
-

- Cloudflare R2 with Next.js: Upload, Download, Delete -

-

Upload File

-
-
- - -
-
- - {isUploading && ( -
-
-
-
-
-

- {uploadProgress.toFixed(2)}% uploaded -

- -
-
- )} - -

Files

- {files.length === 0 ? ( -

No files found.

- ) : ( -
    - {files.map((file) => ( -
  • - {file.Key} -
    - - -
    -
  • - ))} -
- )} -
- ); -} diff --git a/components/shared/icons.tsx b/components/shared/icons.tsx index 6d39694..239b280 100644 --- a/components/shared/icons.tsx +++ b/components/shared/icons.tsx @@ -88,7 +88,47 @@ export const Icons = { camera: Camera, calendar: Calendar, crown: Crown, - cloudUpload: CloudUpload, + cloudUpload: ({ ...props }: LucideProps) => ( + + ), + storage: ({ ...props }: LucideProps) => ( + + ), eye: Eye, lock: LockKeyhole, list: List, diff --git a/config/dashboard.ts b/config/dashboard.ts index 894d6d2..3b308d6 100644 --- a/config/dashboard.ts +++ b/config/dashboard.ts @@ -14,7 +14,7 @@ export const sidebarLinks: SidebarNavItem[] = [ { href: "/emails", icon: "mail", title: "Emails" }, { href: "/dashboard/storage", - icon: "cloudUpload", + icon: "storage", title: "Cloud Storage", }, ], diff --git a/content/docs/developer/cloudflare.mdx b/content/docs/developer/cloudflare.mdx index dbbc9f2..7cb34c5 100644 --- a/content/docs/developer/cloudflare.mdx +++ b/content/docs/developer/cloudflare.mdx @@ -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 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. --- @@ -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 diff --git a/lib/dto/user.ts b/lib/dto/user.ts index b13855d..e82214f 100644 --- a/lib/dto/user.ts +++ b/lib/dto/user.ts @@ -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) { diff --git a/lib/r2.ts b/lib/r2.ts index 3985054..83dd69a 100644 --- a/lib/r2.ts +++ b/lib/r2.ts @@ -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; diff --git a/lib/utils.ts b/lib/utils.ts index 596a99f..e86fbed 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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"; +}; diff --git a/locales/en.json b/locales/en.json index 93c5996..f00b1b7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/zh.json b/locales/zh.json index cb51dcb..9d5b6aa 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -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": "设置", diff --git a/package.json b/package.json index d3d8370..64186ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3012d21..bd4da12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/public/sw.js.map b/public/sw.js.map index cc7c1d9..fe5d752 100644 --- a/public/sw.js.map +++ b/public/sw.js.map @@ -1 +1 @@ -{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/00e4cad826a34a9a927f45ba2035b590/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/d830702919f7d7cb2216bd72b3811a30/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":"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"} \ No newline at end of file