diff --git a/src/lib/api.ts b/src/lib/api.ts index f605df9..db03c9a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,25 +7,6 @@ type FetchOptions = Omit & { body?: any; }; -type UploadProgressEvent = { - percent: number; - phase: "uploading" | "finishing"; -}; - -type UploadOptions = { - params?: Record; - onProgress?: (event: UploadProgressEvent) => void; - timeoutMs?: number; -}; - -export type UploadHandle = { - promise: Promise; - abort: () => void; -}; - -/** Default upload timeout aligned with nginx proxy_read_timeout for /oss/. */ -const DEFAULT_UPLOAD_TIMEOUT_MS = 3_600_000; - export const API_URL = BASE_PATH + "/api"; export class APIError extends Error { @@ -106,93 +87,6 @@ const api = { }); }, - upload( - url: string, - body: FormData, - options?: UploadOptions - ): UploadHandle { - const _url = new URL(API_URL + url, window.location.origin); - - if (options?.params) { - Object.entries(options.params).forEach(([key, value]) => { - _url.searchParams.set(key, String(value)); - }); - } - - const xhr = new XMLHttpRequest(); - xhr.open("PUT", _url.toString(), true); - xhr.withCredentials = true; - xhr.timeout = options?.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS; - - xhr.upload.addEventListener("progress", (event) => { - if (!event.lengthComputable || !options?.onProgress) return; - - const sentPercent = Math.round((event.loaded / event.total) * 100); - options.onProgress({ - phase: "uploading", - percent: Math.min(95, sentPercent), - }); - }); - - xhr.upload.addEventListener("load", () => { - options?.onProgress?.({ phase: "finishing", percent: 99 }); - }); - - const promise = new Promise((resolve, reject) => { - xhr.addEventListener("load", () => { - const contentType = xhr.getResponseHeader("Content-Type") || ""; - const isJson = contentType.includes("application/json"); - let data: unknown; - - try { - data = isJson ? JSON.parse(xhr.responseText) : xhr.responseText; - } catch { - reject(new APIError("Invalid response", xhr.status)); - return; - } - - if (xhr.status === 401 && !url.startsWith("/auth")) { - window.location.href = utils.url("/auth/login"); - reject(new APIError("unauthorized", xhr.status)); - return; - } - - if (xhr.status < 200 || xhr.status >= 300) { - const message = isJson - ? (data as { message?: string })?.message - : typeof data === "string" - ? data - : xhr.statusText; - reject(new APIError(message || xhr.statusText, xhr.status)); - return; - } - - resolve(data as T); - }); - - xhr.addEventListener("abort", () => { - reject(new APIError("Upload aborted", 0)); - }); - - xhr.addEventListener("error", () => { - reject(new APIError("Network error", 0)); - }); - - xhr.addEventListener("timeout", () => { - reject( - new APIError("Upload timed out while waiting for the server", 504) - ); - }); - - xhr.send(body); - }); - - return { - promise, - abort: () => xhr.abort(), - }; - }, - async delete(url: string, options?: Partial) { return this.fetch(url, { ...options, diff --git a/src/lib/pausable-upload.ts b/src/lib/pausable-upload.ts index 9bdd233..c13ec23 100644 --- a/src/lib/pausable-upload.ts +++ b/src/lib/pausable-upload.ts @@ -1,10 +1,9 @@ -//#region 常量/配置 +import { APIError } from "@/lib/api"; const CHUNK_SIZE = 512 * 1024; - -//#endregion - -//#region 模型/类型 +const DEFAULT_UPLOAD_TIMEOUT_MS = 3_600_000; +const PAUSE_POLL_MS = 80; +const MAX_UPLOAD_PERCENT_BEFORE_FINISH = 95; type StreamPhase = "header" | "body" | "footer"; @@ -14,11 +13,6 @@ type PausableStreamState = { offset: number; }; -export type PausableUploadProgress = { - percent: number; - phase: "uploading" | "finishing"; -}; - export type PausableUploadHandle = { promise: Promise; pause: () => void; @@ -26,13 +20,30 @@ export type PausableUploadHandle = { abort: () => void; }; -//#endregion +type UploadProgressEvent = { + percent: number; + phase: "uploading" | "finishing"; +}; -//#region 方法/工具 +type StartPausableUploadOptions = { + onProgress?: (event: UploadProgressEvent) => void; + timeoutMs?: number; + onUnauthorized?: () => void; +}; const escapeMultipartFilename = (name: string) => name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +const abortStream = (controller: ReadableStreamDefaultController) => { + controller.error(new DOMException("Upload aborted", "AbortError")); +}; + +const waitWhilePaused = async (state: PausableStreamState) => { + while (state.paused && !state.aborted) { + await new Promise((resolve) => setTimeout(resolve, PAUSE_POLL_MS)); + } +}; + const createMultipartFileStream = ( file: File, boundary: string, @@ -48,23 +59,16 @@ const createMultipartFileStream = ( const footer = encoder.encode(`\r\n--${boundary}--\r\n`); let phase: StreamPhase = "header"; - const waitWhilePaused = async () => { - while (state.paused && !state.aborted) { - await new Promise((resolve) => setTimeout(resolve, 80)); - } - }; - return new ReadableStream({ async pull(controller) { if (state.aborted) { - controller.error(new DOMException("Upload aborted", "AbortError")); + abortStream(controller); return; } - await waitWhilePaused(); - + await waitWhilePaused(state); if (state.aborted) { - controller.error(new DOMException("Upload aborted", "AbortError")); + abortStream(controller); return; } @@ -77,9 +81,9 @@ const createMultipartFileStream = ( if (phase === "body") { if (state.offset < file.size) { - await waitWhilePaused(); + await waitWhilePaused(state); if (state.aborted) { - controller.error(new DOMException("Upload aborted", "AbortError")); + abortStream(controller); return; } @@ -102,18 +106,31 @@ const createMultipartFileStream = ( }); }; -//#endregion +const parseUploadResponse = async (res: Response) => { + const contentType = res.headers.get("Content-Type") || ""; + const isJson = contentType.includes("application/json"); + const data = isJson ? await res.json() : await res.text(); -//#region 公开 API + if (res.status === 401) { + throw new APIError("unauthorized", 401); + } + + if (!res.ok) { + const message = isJson + ? (data as { message?: string })?.message + : typeof data === "string" + ? data + : res.statusText; + throw new APIError(message || res.statusText, res.status); + } + + return data; +}; export const startPausableUpload = ( url: string, file: File, - options?: { - onProgress?: (event: PausableUploadProgress) => void; - timeoutMs?: number; - onUnauthorized?: () => void; - } + options?: StartPausableUploadOptions ): PausableUploadHandle => { const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`; const state: PausableStreamState = { @@ -122,7 +139,7 @@ export const startPausableUpload = ( offset: 0, }; const abortController = new AbortController(); - const timeoutMs = options?.timeoutMs ?? 3_600_000; + const timeoutMs = options?.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS; let bodyFinished = false; const timeoutId = window.setTimeout(() => { @@ -142,7 +159,7 @@ export const startPausableUpload = ( const sentPercent = Math.round((sent / total) * 100); options?.onProgress?.({ phase: "uploading", - percent: Math.min(95, sentPercent), + percent: Math.min(MAX_UPLOAD_PERCENT_BEFORE_FINISH, sentPercent), }); }); @@ -159,25 +176,14 @@ export const startPausableUpload = ( .then(async (res) => { window.clearTimeout(timeoutId); - const contentType = res.headers.get("Content-Type") || ""; - const isJson = contentType.includes("application/json"); - const data = isJson ? await res.json() : await res.text(); - - if (res.status === 401) { - options?.onUnauthorized?.(); - throw new Error("unauthorized"); + try { + return (await parseUploadResponse(res)) as T; + } catch (error) { + if (error instanceof APIError && error.status === 401) { + options?.onUnauthorized?.(); + } + throw error; } - - if (!res.ok) { - const message = isJson - ? (data as { message?: string })?.message - : typeof data === "string" - ? data - : res.statusText; - throw new Error(message || res.statusText); - } - - return data as T; }) .catch((error) => { window.clearTimeout(timeoutId); @@ -198,5 +204,3 @@ export const startPausableUpload = ( }, }; }; - -//#endregion diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index 696dac7..071f442 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -79,7 +79,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => { }); const onSubmit = form.handleSubmit((values) => { - createFolder.mutate({ key: `${prefix}${values.name}/`, file: null }); + createFolder.mutate({ key: `${prefix}${values.name}/` }); }); return ( diff --git a/src/pages/buckets/manage/browse/file-type-icon.tsx b/src/pages/buckets/manage/browse/file-type-icon.tsx new file mode 100644 index 0000000..56a257c --- /dev/null +++ b/src/pages/buckets/manage/browse/file-type-icon.tsx @@ -0,0 +1,39 @@ +import mime from "mime/lite"; +import { + FileArchive, + FileIcon, + FileType, + type LucideIcon, +} from "lucide-react"; + +const ARCHIVE_EXTENSIONS = [ + "zip", + "rar", + "7z", + "iso", + "tar", + "gz", + "bz2", + "xz", +]; + +export const getBrowseFileIcon = (ext?: string | null): LucideIcon => { + if (ARCHIVE_EXTENSIONS.includes(ext || "")) return FileArchive; + + const type = mime.getType(ext || "")?.split("/")[0]; + if (type === "text") return FileType; + + return FileIcon; +}; + +type Props = { + ext?: string | null; + className?: string; +}; + +const BrowseFileTypeIcon = ({ ext, className }: Props) => { + const Icon = getBrowseFileIcon(ext); + return ; +}; + +export default BrowseFileTypeIcon; diff --git a/src/pages/buckets/manage/browse/hooks.ts b/src/pages/buckets/manage/browse/hooks.ts index 08f2808..e0bbe3d 100644 --- a/src/pages/buckets/manage/browse/hooks.ts +++ b/src/pages/buckets/manage/browse/hooks.ts @@ -26,17 +26,9 @@ export const usePutObject = ( options?: UseMutationOptions ) => { return useMutation({ - mutationFn: async (body) => { + mutationFn: ({ key }) => { const formData = new FormData(); - if (body.file) { - formData.append("file", body.file); - const handle = api.upload(`/browse/${bucket}/${body.key}`, formData, { - onProgress: body.onProgress, - }); - return handle.promise; - } - - return api.put(`/browse/${bucket}/${body.key}`, { body: formData }); + return api.put(`/browse/${bucket}/${key}`, { body: formData }); }, ...options, }); diff --git a/src/pages/buckets/manage/browse/object-list.tsx b/src/pages/buckets/manage/browse/object-list.tsx index ce2fffd..94144c1 100644 --- a/src/pages/buckets/manage/browse/object-list.tsx +++ b/src/pages/buckets/manage/browse/object-list.tsx @@ -74,14 +74,8 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => { ) : null} - {uploadItems.map((item, idx) => ( - = uploadItems.length - 2 && uploadItems.length > 5 - } - /> + {uploadItems.map((item) => ( + ))} {data?.prefixes.map((prefix) => ( diff --git a/src/pages/buckets/manage/browse/types.ts b/src/pages/buckets/manage/browse/types.ts index d3fa088..3a84375 100644 --- a/src/pages/buckets/manage/browse/types.ts +++ b/src/pages/buckets/manage/browse/types.ts @@ -18,11 +18,6 @@ export type Object = { url: string; }; -export type UploadProgressEvent = { - percent: number; - phase: "uploading" | "finishing"; -}; - export type UploadRowPhase = "uploading" | "finishing" | "paused"; export type UploadRowStatus = @@ -46,6 +41,4 @@ export type UploadRowItem = { export type PutObjectPayload = { key: string; - file: File | null; - onProgress?: (event: UploadProgressEvent) => void; }; diff --git a/src/pages/buckets/manage/browse/upload-queue.tsx b/src/pages/buckets/manage/browse/upload-queue.tsx index b700fcf..39e187d 100644 --- a/src/pages/buckets/manage/browse/upload-queue.tsx +++ b/src/pages/buckets/manage/browse/upload-queue.tsx @@ -8,9 +8,10 @@ import { type ReactNode, } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { APIError, API_URL } from "@/lib/api"; +import { API_URL } from "@/lib/api"; import { startPausableUpload } from "@/lib/pausable-upload"; -import * as utils from "@/lib/utils"; +import { url } from "@/lib/utils"; +import { toUploadErrorMessage } from "./upload-status"; import { UploadRowItem, UploadRowPhase, @@ -39,19 +40,25 @@ const createUploadId = () => ? crypto.randomUUID() : `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`; -const toUploadErrorMessage = (error: unknown) => { - if (error instanceof DOMException && error.name === "AbortError") return null; - if (error instanceof APIError) { - if (error.status === 0) return "Network error. Check your connection."; - if (error.status === 413) return "File exceeds the server upload size limit."; - if (error.status === 502 || error.status === 504) { - return "Server timed out while saving. Retry or use the S3 API for large files."; - } - if (error.message) return error.message; - } - if (error instanceof Error && error.message) return error.message; - return "Upload failed."; -}; +const progressToStatus = (phase: UploadRowPhase): UploadRowStatus => + phase === "finishing" ? "finishing" : "uploading"; + +const createUploadItem = ( + id: string, + bucket: string, + prefix: string, + file: File +): UploadRowItem => ({ + id, + bucket, + prefix, + key: prefix + file.name, + fileName: file.name, + size: file.size, + percent: 0, + phase: "uploading", + status: "uploading", +}); export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const queryClient = useQueryClient(); @@ -76,28 +83,28 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const runUpload = useCallback( (id: string, bucket: string, key: string, file: File) => { - const url = `${API_URL}/browse/${bucket}/${key}`; let lastEventKey = ""; - const handle = startPausableUpload(url, file, { - onUnauthorized: () => { - window.location.href = utils.url("/auth/login"); - }, - onProgress: (event) => { - const eventKey = `${event.phase}:${event.percent}`; - if (eventKey === lastEventKey) return; - lastEventKey = eventKey; + const handle = startPausableUpload( + `${API_URL}/browse/${bucket}/${key}`, + file, + { + onUnauthorized: () => { + window.location.href = url("/auth/login"); + }, + onProgress: (event) => { + const eventKey = `${event.phase}:${event.percent}`; + if (eventKey === lastEventKey) return; + lastEventKey = eventKey; - const status: UploadRowStatus = - event.phase === "finishing" ? "finishing" : "uploading"; - - patchItem(id, { - percent: event.percent, - phase: event.phase as UploadRowPhase, - status, - }); - }, - }); + patchItem(id, { + percent: event.percent, + phase: event.phase as UploadRowPhase, + status: progressToStatus(event.phase as UploadRowPhase), + }); + }, + } + ); activeRef.current.set(id, { pause: handle.pause, @@ -121,10 +128,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const message = toUploadErrorMessage(error); if (!message) return; - patchItem(id, { - status: "error", - errorMessage: message, - }); + patchItem(id, { status: "error", errorMessage: message }); }); }, [patchItem, queryClient, removeItem] @@ -134,24 +138,8 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { (bucket: string, prefix: string, files: File[]) => { for (const file of files) { const id = createUploadId(); - const key = prefix + file.name; - - setItems((prev) => [ - ...prev, - { - id, - bucket, - prefix, - key, - fileName: file.name, - size: file.size, - percent: 0, - phase: "uploading", - status: "uploading", - }, - ]); - - runUpload(id, bucket, key, file); + setItems((prev) => [...prev, createUploadItem(id, bucket, prefix, file)]); + runUpload(id, bucket, prefix + file.name, file); } }, [runUpload] @@ -163,10 +151,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { if (!active) return; active.pause(); - patchItem(id, { - status: "paused", - phase: "paused", - }); + patchItem(id, { status: "paused", phase: "paused" }); }, [patchItem] ); @@ -174,7 +159,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const resumeUpload = useCallback( (id: string) => { const active = activeRef.current.get(id); - const item = items.find((i) => i.id === id); + const item = items.find((entry) => entry.id === id); if (!active || !item || item.status !== "paused") return; patchItem(id, { @@ -189,7 +174,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { const cancelUpload = useCallback( (id: string) => { - const item = items.find((i) => i.id === id); + const item = items.find((entry) => entry.id === id); if (!item) return; if ( @@ -202,11 +187,8 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => { cancelledRef.current.add(id); const active = activeRef.current.get(id); - if (active) { - active.abort(); - } else { - removeItem(id); - } + if (active) active.abort(); + else removeItem(id); }, [items, removeItem] ); diff --git a/src/pages/buckets/manage/browse/upload-row-actions.tsx b/src/pages/buckets/manage/browse/upload-row-actions.tsx index 434e85c..f524f7c 100644 --- a/src/pages/buckets/manage/browse/upload-row-actions.tsx +++ b/src/pages/buckets/manage/browse/upload-row-actions.tsx @@ -5,10 +5,9 @@ import { useUploadQueue } from "./upload-queue"; type Props = { item: UploadRowItem; - end?: boolean; }; -const UploadRowActions = ({ item, end: _end }: Props) => { +const UploadRowActions = ({ item }: Props) => { const { pauseUpload, resumeUpload, cancelUpload } = useUploadQueue(); const isPaused = item.status === "paused"; const isActive = item.status === "uploading" || item.status === "finishing"; diff --git a/src/pages/buckets/manage/browse/upload-row.tsx b/src/pages/buckets/manage/browse/upload-row.tsx index 16e7351..cc3549f 100644 --- a/src/pages/buckets/manage/browse/upload-row.tsx +++ b/src/pages/buckets/manage/browse/upload-row.tsx @@ -1,26 +1,21 @@ -import { AlertCircle, FileArchive, FileIcon, FileType } from "lucide-react"; -import mime from "mime/lite"; +import { AlertCircle } from "lucide-react"; import { readableBytes } from "@/lib/utils"; +import BrowseFileTypeIcon from "./file-type-icon"; +import { getUploadStatusLabel } from "./upload-status"; import { UploadRowItem } from "./types"; import UploadRowActions from "./upload-row-actions"; type Props = { item: UploadRowItem; - end?: boolean; }; -const statusLabel = (item: UploadRowItem) => { - if (item.status === "error") return item.errorMessage ?? "Upload failed"; - if (item.status === "paused") return `Paused at ${item.percent}%`; - if (item.status === "finishing") return "Saving to storage…"; - return `Uploading — ${item.percent}%`; +const fileExtension = (fileName: string) => { + const dotIndex = fileName.lastIndexOf("."); + return dotIndex >= 0 ? fileName.substring(dotIndex + 1) : null; }; -const UploadRow = ({ item, end }: Props) => { - const extIdx = item.fileName.lastIndexOf("."); - const extBare = - extIdx >= 0 ? item.fileName.substring(extIdx + 1) : null; - +const UploadRow = ({ item }: Props) => { + const status = getUploadStatusLabel(item); const isError = item.status === "error"; return ( @@ -37,7 +32,10 @@ const UploadRow = ({ item, end }: Props) => { aria-hidden /> ) : ( - + )} {item.fileName} @@ -45,38 +43,13 @@ const UploadRow = ({ item, end }: Props) => { {readableBytes(item.size)} - - {statusLabel(item)} + + {status} - + ); }; -type UploadFileIconProps = { - ext: string | null; -}; - -const UploadFileIcon = ({ ext }: UploadFileIconProps) => { - const type = mime.getType(ext || "")?.split("/")[0]; - let Icon = FileIcon; - - if ( - ["zip", "rar", "7z", "iso", "tar", "gz", "bz2", "xz"].includes(ext || "") - ) { - Icon = FileArchive; - } else if (type === "text") { - Icon = FileType; - } - - return ( - - ); -}; - export default UploadRow; diff --git a/src/pages/buckets/manage/browse/upload-status.ts b/src/pages/buckets/manage/browse/upload-status.ts new file mode 100644 index 0000000..7b6b42b --- /dev/null +++ b/src/pages/buckets/manage/browse/upload-status.ts @@ -0,0 +1,25 @@ +import { APIError } from "@/lib/api"; +import { UploadRowItem } from "./types"; + +export const getUploadStatusLabel = (item: UploadRowItem) => { + if (item.status === "error") return item.errorMessage ?? "Upload failed"; + if (item.status === "paused") return `Paused at ${item.percent}%`; + if (item.status === "finishing") return "Saving to storage…"; + return `Uploading — ${item.percent}%`; +}; + +export const toUploadErrorMessage = (error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return null; + + if (error instanceof APIError) { + if (error.status === 0) return "Network error. Check your connection."; + if (error.status === 413) return "File exceeds the server upload size limit."; + if (error.status === 502 || error.status === 504) { + return "Server timed out while saving. Retry or use the S3 API for large files."; + } + if (error.message) return error.message; + } + + if (error instanceof Error && error.message) return error.message; + return "Upload failed."; +};