diff --git a/app/(protected)/admin/system/s3-list.tsx b/app/(protected)/admin/system/s3-list.tsx index dbc9aa7..3a967bf 100644 --- a/app/(protected)/admin/system/s3-list.tsx +++ b/app/(protected)/admin/system/s3-list.tsx @@ -9,7 +9,7 @@ import { toast } from "sonner"; import useSWR from "swr"; import { CloudStorageCredentials } from "@/lib/r2"; -import { cn, fetcher } from "@/lib/utils"; +import { cn, fetcher, formatFileSize } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -140,7 +140,7 @@ export default function S3Configs({}: {}) {
- +
- + @@ -165,7 +165,7 @@ export default function S3Configs({}: {}) { />
- + @@ -177,7 +177,7 @@ export default function S3Configs({}: {}) { />
- + @@ -191,7 +191,7 @@ export default function S3Configs({}: {}) {
{r2Credentials.buckets.map((bucket, index) => ( -

- Bucket {index + 1} +

+ {t("Bucket")} {index + 1}

{index > 0 && ( @@ -289,7 +289,7 @@ export default function S3Configs({}: {}) {
- +
- + { const newBuckets = [...r2Credentials.buckets]; newBuckets[index] = { @@ -325,25 +325,33 @@ export default function S3Configs({}: {}) { />
- - { - const newBuckets = [...r2Credentials.buckets]; - newBuckets[index] = { - ...bucket, - file_size: e.target.value, - }; - setR2Credentials({ - ...r2Credentials, - buckets: newBuckets, - }); - }} - /> + +
+ { + const newBuckets = [...r2Credentials.buckets]; + newBuckets[index] = { + ...bucket, + file_size: e.target.value, + }; + setR2Credentials({ + ...r2Credentials, + buckets: newBuckets, + }); + }} + /> + + = + {formatFileSize(Number(bucket.file_size || "0"), { + precision: 0, + })} + +
- +
- + { const newBuckets = [...r2Credentials.buckets]; newBuckets[index] = { @@ -379,10 +389,13 @@ export default function S3Configs({}: {}) { />
- + { const newBuckets = [...r2Credentials.buckets]; newBuckets[index] = { diff --git a/app/(protected)/dashboard/storage/loading.tsx b/app/(protected)/dashboard/storage/loading.tsx index 4cb1393..2123322 100644 --- a/app/(protected)/dashboard/storage/loading.tsx +++ b/app/(protected)/dashboard/storage/loading.tsx @@ -8,12 +8,6 @@ export default function DashboardRecordsLoading() { heading="Manage DNS Records" text="List and manage records" /> -
- - - - -
); diff --git a/app/api/storage/r2/files/route.ts b/app/api/storage/r2/files/route.ts index eeaf099..f186bff 100644 --- a/app/api/storage/r2/files/route.ts +++ b/app/api/storage/r2/files/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getUserFiles } from "@/lib/dto/files"; +import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files"; import { getMultipleConfigs } from "@/lib/dto/system-config"; import { checkUserStatus } from "@/lib/dto/user"; import { @@ -118,9 +118,9 @@ export async function DELETE(request: NextRequest) { const user = checkUserStatus(await getCurrentUser()); if (user instanceof Response) return user; - const { key, bucket } = await request.json(); + const { keys, ids, bucket } = await request.json(); - if (!key || !bucket) { + if (!keys || !ids || !bucket) { return NextResponse.json("key and bucket is required", { status: 400, }); @@ -149,15 +149,16 @@ export async function DELETE(request: NextRequest) { }); } - await deleteFile( - key, - createS3Client( - configs.s3_config_01.endpoint, - configs.s3_config_01.access_key_id, - configs.s3_config_01.secret_access_key, - ), - bucket, + const R2 = createS3Client( + configs.s3_config_01.endpoint, + configs.s3_config_01.access_key_id, + configs.s3_config_01.secret_access_key, ); + + for (const key of keys) { + await deleteFile(key, R2, bucket); + } + await softDeleteUserFiles(ids); return NextResponse.json({ message: "File deleted successfully" }); } catch (error) { return NextResponse.json({ error: "Error deleting file" }, { status: 500 }); diff --git a/app/api/storage/r2/uploads/route.ts b/app/api/storage/r2/uploads/route.ts index c68787a..c9e7ec7 100644 --- a/app/api/storage/r2/uploads/route.ts +++ b/app/api/storage/r2/uploads/route.ts @@ -10,7 +10,7 @@ import { import { createUserFile } from "@/lib/dto/files"; import { getMultipleConfigs } from "@/lib/dto/system-config"; import { checkUserStatus } from "@/lib/dto/user"; -import { CloudStorageCredentials, createS3Client, getFileInfo } from "@/lib/r2"; +import { CloudStorageCredentials, createS3Client } from "@/lib/r2"; import { getCurrentUser } from "@/lib/session"; import { extractFileNameAndExtension, generateFileKey } from "@/lib/utils"; diff --git a/components/file/file-list.tsx b/components/file/file-list.tsx index cc51fcf..6cb6477 100644 --- a/components/file/file-list.tsx +++ b/components/file/file-list.tsx @@ -1,28 +1,31 @@ "use client"; -import React, { useEffect, useState, useTransition } from "react"; +import React, { useState, useTransition } from "react"; +import Link from "next/link"; import { Archive, - Calendar, - Code2, Download, FileCode, FileSpreadsheet, FileText, FileType2, Folder, - HardDrive, Image, - Presentation, Trash2, } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import useSWR, { useSWRConfig } from "swr"; -import { FileObject } from "@/lib/r2"; +import { UserFileData } from "@/lib/dto/files"; import { extractFileNameAndExtension, + fetcher, formatDate, formatFileSize, + truncateMiddle, } from "@/lib/utils"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { Tooltip, TooltipContent, @@ -31,39 +34,76 @@ import { } from "@/components/ui/tooltip"; import { BucketInfo, DisplayType } from "@/components/file"; +import { CopyButton } from "../shared/copy-button"; +import { EmptyPlaceholder } from "../shared/empty-placeholder"; +import { Icons } from "../shared/icons"; +import { PaginationWrapper } from "../shared/pagination"; +import { TimeAgoIntl } from "../shared/time-ago"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { Skeleton } from "../ui/skeleton"; +import { TableCell, TableRow } from "../ui/table"; -export default function FileManager({ +export default function UserFileList({ bucketInfo, action, view, + currentPage, + pageSize, + setCurrentPage, + setPageSize, + onRefresh, }: { bucketInfo: BucketInfo; action: string; view: DisplayType; + currentPage: number; + pageSize: number; + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + onRefresh: () => void; }) { - const [files, setFiles] = useState([]); - const [isLoadingFiles, startLoadingFiles] = useTransition(); + const t = useTranslations("List"); + const { isMobile } = useMediaQuery(); - useEffect(() => { - if (bucketInfo.bucket) { - fetchFiles(); + const [selectedFiles, setSelectedFiles] = useState([]); + // const [showMutiCheckBox, setShowMutiCheckBox] = useState(false); + + const { data: files, isLoading } = useSWR<{ + total: number; + totalSize: number; + list: UserFileData[]; + }>( + bucketInfo.bucket + ? `${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}` + : null, + fetcher, + { + revalidateOnFocus: false, + }, + ); + + const handleSelectFile = (file: UserFileData) => { + if (selectedFiles.includes(file)) { + setSelectedFiles(selectedFiles.filter((f) => f.id !== file.id)); + } else { + setSelectedFiles([...selectedFiles, file]); } - }, [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 handleSelectAllFiles = () => { + if (selectedFiles.length === files?.list.length) { + setSelectedFiles([]); + } else { + setSelectedFiles(files?.list || []); + } }; const handleDownload = async (key: string) => { @@ -81,68 +121,211 @@ export default function FileManager({ } }; - const handleDelete = async (key: string) => { - if (!confirm("确定要删除这个文件吗?")) return; + const handleDeleteMany = async () => { + try { + await fetch(`${action}/r2/files`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + keys: selectedFiles.map((f) => f.path), + ids: selectedFiles.map((f) => f.id), + bucket: bucketInfo.bucket, + }), + }); + toast.success("File deleted successfully!"); + onRefresh(); + } catch (error) { + console.error("Error deleting file:", error); + toast.success("Error deleting file"); + } + }; + + const handleDeleteSingle = async (file: UserFileData) => { + if (!confirm("Are you sure you want to delete this file?")) return; try { await fetch(`${action}/r2/files`, { method: "DELETE", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key, bucket: bucketInfo.bucket }), + body: JSON.stringify({ + keys: [file.path], + ids: [file.id], + bucket: bucketInfo.bucket, + }), }); - alert("File deleted successfully!"); - fetchFiles(); + toast.success("File deleted successfully!"); + onRefresh(); } catch (error) { console.error("Error deleting file:", error); - alert("Error deleting file"); + toast.success("Error deleting file"); } }; + if (files?.total === 0) { + return ( + + + {t("No Files")} + + {t("You don't upload any files yet")} + + + ); + } + const renderListView = () => ( -
-
-
名称
-
大小
-
修改时间
-
操作
+
+
+
+ handleSelectAllFiles()} + /> +
+
{t("Name")}
+
{t("Size")}
+
{t("Type")}
+
{t("User")}
+
{t("Date")}
+
{t("Actions")}
-
- {files.map((file) => ( -
-
- {getFileIcon(file.Key || "", bucketInfo)} - {file.Key} -
-
- - {formatFileSize(file.Size || 0)} -
-
- - {formatDate(file.LastModified?.toString() || "")} -
-
- - -
-
- ))} -
+
e.stopPropagation()} + > + f.id === file.id) !== undefined + } + onCheckedChange={() => handleSelectFile(file)} + className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300" + /> +
+
+ + + + {truncateMiddle(file.path)} + + + + {file.mimeType.startsWith("image/") ? ( + {`${file.path}`} + ) : ( + file.path + )} + + + +
+
+ {formatFileSize(file.size || 0)} +
+
+ {file.mimeType || "-"} +
+
+ + + + {file.user.name ?? file.user.email} + + +

{file.user.name}

+

{file.user.email}

+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
+
+ ))} +
+ )}
); @@ -153,197 +336,255 @@ export default function FileManager({ gridTemplateColumns: "repeat(auto-fill, minmax(10px, 100px))", }} > - {files.map((file) => ( -
-
- {React.cloneElement(getFileIcon(file.Key || "", bucketInfo), { - size: 40, - })} -
- - - - {truncateMiddle(file.Key || "")} - - - {["jpg", "jpeg", "png", "gif", "webp"].includes( - extractFileNameAndExtension(file.Key || "").extension || - "", - ) && ( - {`${file.Key}`} - )} -

{file.Key}

-

- Size: {formatFileSize(file.Size || 0)} -

-

- Modified:{" "} - {formatDate(file.LastModified?.toString() || "")} -

-
- - -
-
-
-
+ {isLoading && + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( + + ))} + {files && + files.list.map((file) => ( +
+
+ {React.cloneElement( + getFileIcon(file.path, file.mimeType, bucketInfo), + { + size: 40, + }, + )} +
+ + + + {truncateMiddle(file.path || "")} + + + {file.mimeType.startsWith("image/") && ( + {`${file.path}`} + )} +
+ + {file.path} + + +
+

+ Size: {formatFileSize(file.size || 0)} +

+

+ Type: {file.mimeType || "-"} +

+

+ Modified:{" "} + {formatDate(file.lastModified?.toString() || "")} +

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

暂无文件

- )} -
- ) : ( - <>{view === "List" ? renderListView() : renderGridView()} + <> + {view === "List" ? renderListView() : renderGridView()} + {files && Math.ceil(files.total / pageSize) > 1 && ( + )} -
+ ); } -const getFileIcon = (filename: string, bucketInfo: BucketInfo) => { - const ext = filename.split(".").pop()?.toLowerCase(); +function TableColumnSekleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +const getFileIcon = ( + filename: string, + mimeType: string | null, + bucketInfo: BucketInfo, +) => { const iconProps = { size: 24, className: "text-gray-600" }; - switch (ext) { - case "jpg": - case "jpeg": - case "png": - case "gif": - case "webp": - return ( - {filename} - ); - case "svg": + // 如果没有 mimeType,回退到文件夹判断 + if (!mimeType) { + if (filename.endsWith("/")) { + return ; + } + return ; + } + + // 图片类型 - 直接显示图片 + if (mimeType.startsWith("image/")) { + if (mimeType === "image/svg+xml") { 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 ; + } + // 其他图片格式显示缩略图 + return ( + {filename} + ); } -}; - -const truncateMiddle = (text: string, maxLength: number = 20): string => { - if (text.length <= maxLength) return text; - - // 找到最后一个点的位置(文件扩展名) - const lastDotIndex = text.lastIndexOf("."); - - if (lastDotIndex === -1 || lastDotIndex === 0) { - // 没有扩展名,直接中间截断 - const half = Math.floor((maxLength - 3) / 2); - return text.slice(0, half) + "..." + text.slice(-half); - } - - const extension = text.slice(lastDotIndex); - const nameWithoutExt = text.slice(0, lastDotIndex); - - // 如果扩展名太长,直接截断整个文件名 - if (extension.length > maxLength / 2) { - const half = Math.floor((maxLength - 3) / 2); - return text.slice(0, half) + "..." + text.slice(-half); - } - - // 计算可用于文件名的长度 - const availableLength = maxLength - extension.length - 3; - - if (availableLength <= 0) { - return "..." + extension; - } - - // 如果文件名部分不需要截断 - if (nameWithoutExt.length <= availableLength) { - return text; - } - - // 中间截断文件名部分 - const startLength = Math.ceil(availableLength / 2); - const endLength = Math.floor(availableLength / 2); - - return ( - nameWithoutExt.slice(0, startLength) + - "..." + - nameWithoutExt.slice(-endLength) + - extension - ); + + // 压缩文件 + if ( + mimeType === "application/zip" || + mimeType === "application/x-rar-compressed" || + mimeType === "application/x-7z-compressed" || + mimeType === "application/x-tar" || + mimeType === "application/gzip" || + mimeType === "application/x-gzip" + ) { + return ; + } + + // Microsoft Office 文档 + if ( + mimeType === + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || + mimeType === "application/msword" + ) { + return ; + } + + // Microsoft Office 演示文稿 + if ( + mimeType === + "application/vnd.openxmlformats-officedocument.presentationml.presentation" || + mimeType === "application/vnd.ms-powerpoint" + ) { + return ; + } + + // Microsoft Office 电子表格 + if ( + mimeType === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + mimeType === "application/vnd.ms-excel" || + mimeType === "text/csv" + ) { + return ; + } + + // JSON 文件 + if (mimeType === "application/json") { + return ; + } + + // Markdown 文件 + if (mimeType === "text/markdown" || mimeType === "text/x-markdown") { + return ; + } + + // 代码文件 + if ( + mimeType.startsWith("text/") || + mimeType === "application/javascript" || + mimeType === "application/typescript" || + mimeType === "application/x-javascript" || + mimeType === "text/javascript" || + mimeType === "text/typescript" + ) { + return ; + } + + // PDF 文件 + if (mimeType === "application/pdf") { + return ; + } + + // 音频文件 + if (mimeType.startsWith("audio/")) { + return ; + } + + // 视频文件 + if (mimeType.startsWith("video/")) { + return ; + } + + // 默认文件图标 + return ; }; diff --git a/components/file/index.tsx b/components/file/index.tsx index 9b91b4b..6fc911c 100644 --- a/components/file/index.tsx +++ b/components/file/index.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from "react"; import { User } from "@prisma/client"; -import useSWR from "swr"; +import { RefreshCwIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import useSWR, { useSWRConfig } from "swr"; import { BucketItem, ClientStorageCredentials } from "@/lib/r2"; import { fetcher } from "@/lib/utils"; @@ -12,16 +14,18 @@ import { 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-list"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import UserFileList from "@/components/file/file-list"; import Uploader from "@/components/file/uploader"; import { Icons } from "@/components/shared/icons"; +import { EmptyPlaceholder } from "../shared/empty-placeholder"; +import { Button } from "../ui/button"; + export interface FileListProps { user: Pick; action: string; @@ -35,7 +39,10 @@ export interface BucketInfo extends BucketItem { export type DisplayType = "List" | "Grid"; -export default function UserFileList({ user, action }: FileListProps) { +export default function UserFileManager({ user, action }: FileListProps) { + const t = useTranslations("List"); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); const [displayType, setDisplayType] = useState("List"); const [bucketInfo, setBucketInfo] = useState({ bucket: "", @@ -46,6 +53,8 @@ export default function UserFileList({ user, action }: FileListProps) { provider_name: "", }); + const { mutate } = useSWRConfig(); + const { data: r2Configs, isLoading } = useSWR( `${action}/r2/configs`, fetcher, @@ -63,6 +72,13 @@ export default function UserFileList({ user, action }: FileListProps) { } }, [r2Configs]); + const handleRefresh = () => { + mutate( + `${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`, + undefined, + ); + }; + const handleChangeBucket = (bucket: string) => { const newBucketInfo = r2Configs?.buckets?.find( (item) => item.bucket === bucket, @@ -74,59 +90,107 @@ export default function UserFileList({ user, action }: FileListProps) { }; return ( - <> -
- -
- - setDisplayType("List")}> - - - setDisplayType("Grid")}> - - - +
+ +
+ + setDisplayType("List")}> + + + setDisplayType("Grid")}> + + + + {isLoading ? ( + + ) : ( + + )} + + + +
- + {isLoading && ( +
+
+ +
+
+ + {t("Loading storage buckets")}... +
+ )} - + + + {t("No buckets found")} + + + {t( + "The administrator has not configured the storage bucket, no file can be uploaded", + )} + + + )} + + {!isLoading && r2Configs?.buckets && r2Configs.buckets.length > 0 && ( + -
-
- + )} + +
); } diff --git a/components/file/uploader.tsx b/components/file/uploader.tsx index 7c7c5c7..cfd435d 100644 --- a/components/file/uploader.tsx +++ b/components/file/uploader.tsx @@ -37,9 +37,11 @@ export type UploadProgressType = { export default function Uploader({ bucketInfo, action, + onRefresh, }: { bucketInfo: BucketInfo; action: string; + onRefresh: () => void; }) { const t = useTranslations("Components"); const [isOpen, setIsOpen] = useState(false); @@ -230,6 +232,8 @@ export default function Uploader({ : item, ) ?? [], ); + + onRefresh(); } catch (error) { console.error(error); } @@ -246,7 +250,7 @@ export default function Uploader({ <> {!isOpen && (