From 2e1a9d078fd39261f2870eb304cdcc5366027969 Mon Sep 17 00:00:00 2001 From: Norman-W <85535885@qq.com> Date: Sat, 20 Jun 2026 13:37:28 +0800 Subject: [PATCH 1/3] fix: mount file input in DOM for reliable bucket uploads Extract openFilePicker() into lib/utils using the same off-screen DOM pattern as copyToClipboard(). The previous handler called input.remove() right after click(), so change never fired in some browsers and uploads silently failed with no toast or network request. Co-authored-by: Cursor --- src/lib/utils.ts | 47 +++++++++++++++++++++ src/pages/buckets/manage/browse/actions.tsx | 18 ++------ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5ebd62c..65f882b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -29,6 +29,53 @@ export const handleError = (err: unknown) => { toast.error((err as Error)?.message || "Unknown error"); }; +/** Delay before removing an unused file input after the picker closes without a selection. */ +const FILE_PICKER_CANCEL_CLEANUP_MS = 500; + +export type OpenFilePickerOptions = { + multiple?: boolean; +}; + +/** + * Opens the native file picker and passes the chosen files to `onSelect`. + * The input is mounted in the document until selection completes; removing it + * immediately after `click()` prevents `change` from firing in some browsers. + */ +export const openFilePicker = ( + onSelect: (files: FileList) => void, + options: OpenFilePickerOptions = {} +) => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = options.multiple ?? false; + + // Keep off-screen but attached, same approach as copyToClipboard(). + input.style.position = "absolute"; + input.style.left = "-999999px"; + + const cleanup = () => { + input.remove(); + window.removeEventListener("focus", onCancelFocus); + }; + + const onCancelFocus = () => { + // Cancel closes the picker without firing change; defer so change can run first. + setTimeout(() => { + if (document.body.contains(input)) cleanup(); + }, FILE_PICKER_CANCEL_CLEANUP_MS); + }; + + input.onchange = (e) => { + cleanup(); + const files = (e.target as HTMLInputElement).files; + if (files?.length) onSelect(files); + }; + + document.body.prepend(input); + window.addEventListener("focus", onCancelFocus, { once: true }); + input.click(); +}; + export const copyToClipboard = async (text: string) => { let textArea: HTMLTextAreaElement | undefined; diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index fea7b84..9b616a5 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -2,7 +2,7 @@ import { FolderPlus, UploadIcon } from "lucide-react"; import Button from "@/components/ui/button"; import { usePutObject } from "./hooks"; import { toast } from "sonner"; -import { handleError } from "@/lib/utils"; +import { handleError, openFilePicker } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; import { useBucketContext } from "../context"; import { useDisclosure } from "@/hooks/useDisclosure"; @@ -30,16 +30,7 @@ const Actions = ({ prefix }: Props) => { }); const onUploadFile = () => { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - - input.onchange = (e) => { - const files = (e.target as HTMLInputElement).files; - if (!files?.length) { - return; - } - + openFilePicker((files) => { if (files.length > 20) { toast.error("You can only upload up to 20 files at a time"); return; @@ -49,10 +40,7 @@ const Actions = ({ prefix }: Props) => { const key = prefix + file.name; putObject.mutate({ key, file }); } - }; - - input.click(); - input.remove(); + }, { multiple: true }); }; return ( From 2e4019ac6da9177ffbd9888a25edb4ef50835846 Mon Sep 17 00:00:00 2001 From: Norman-W <85535885@qq.com> Date: Sat, 20 Jun 2026 16:02:50 +0800 Subject: [PATCH 2/3] feat: inline upload queue rows with progress and pause/resume Show in-progress uploads directly in the browse file list instead of toasts, with percent/status text, pause/resume/cancel controls, and a pausable streaming upload that survives tab switches within the bucket. Co-authored-by: Cursor --- src/app/app.tsx | 6 +- src/app/styles.css | 5 + src/lib/api.ts | 106 ++++++++ src/lib/pausable-upload.ts | 202 ++++++++++++++ src/pages/buckets/manage/browse/actions.tsx | 24 +- src/pages/buckets/manage/browse/hooks.ts | 4 + .../buckets/manage/browse/object-list.tsx | 31 ++- src/pages/buckets/manage/browse/types.ts | 27 ++ .../buckets/manage/browse/upload-queue.tsx | 252 ++++++++++++++++++ .../manage/browse/upload-row-actions.tsx | 42 +++ .../buckets/manage/browse/upload-row.tsx | 82 ++++++ src/pages/buckets/manage/page.tsx | 5 +- 12 files changed, 764 insertions(+), 22 deletions(-) create mode 100644 src/lib/pausable-upload.ts create mode 100644 src/pages/buckets/manage/browse/upload-queue.tsx create mode 100644 src/pages/buckets/manage/browse/upload-row-actions.tsx create mode 100644 src/pages/buckets/manage/browse/upload-row.tsx diff --git a/src/app/app.tsx b/src/app/app.tsx index 535a82e..5bf3e5d 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -14,7 +14,11 @@ const App = () => { - + ); diff --git a/src/app/styles.css b/src/app/styles.css index c49feab..a324023 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -36,4 +36,9 @@ .modal-backdrop { @apply border-none outline-none; } + + /* Sonner — follow daisyUI theme tokens. */ + [data-sonner-toaster] [data-sonner-toast].garage-toast-host { + @apply bg-base-100 text-base-content border border-base-300 shadow-lg; + } } diff --git a/src/lib/api.ts b/src/lib/api.ts index db03c9a..f605df9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,6 +7,25 @@ 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 { @@ -87,6 +106,93 @@ 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 new file mode 100644 index 0000000..9bdd233 --- /dev/null +++ b/src/lib/pausable-upload.ts @@ -0,0 +1,202 @@ +//#region 常量/配置 + +const CHUNK_SIZE = 512 * 1024; + +//#endregion + +//#region 模型/类型 + +type StreamPhase = "header" | "body" | "footer"; + +type PausableStreamState = { + paused: boolean; + aborted: boolean; + offset: number; +}; + +export type PausableUploadProgress = { + percent: number; + phase: "uploading" | "finishing"; +}; + +export type PausableUploadHandle = { + promise: Promise; + pause: () => void; + resume: () => void; + abort: () => void; +}; + +//#endregion + +//#region 方法/工具 + +const escapeMultipartFilename = (name: string) => + name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + +const createMultipartFileStream = ( + file: File, + boundary: string, + state: PausableStreamState, + onBytesSent: (sent: number, total: number) => void +) => { + const encoder = new TextEncoder(); + const header = encoder.encode( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${escapeMultipartFilename(file.name)}"\r\n` + + `Content-Type: ${file.type || "application/octet-stream"}\r\n\r\n` + ); + 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")); + return; + } + + await waitWhilePaused(); + + if (state.aborted) { + controller.error(new DOMException("Upload aborted", "AbortError")); + return; + } + + if (phase === "header") { + controller.enqueue(header); + phase = "body"; + onBytesSent(0, file.size); + return; + } + + if (phase === "body") { + if (state.offset < file.size) { + await waitWhilePaused(); + if (state.aborted) { + controller.error(new DOMException("Upload aborted", "AbortError")); + return; + } + + const end = Math.min(state.offset + CHUNK_SIZE, file.size); + const chunk = await file.slice(state.offset, end).arrayBuffer(); + controller.enqueue(new Uint8Array(chunk)); + state.offset = end; + onBytesSent(state.offset, file.size); + return; + } + + phase = "footer"; + } + + if (phase === "footer") { + controller.enqueue(footer); + controller.close(); + } + }, + }); +}; + +//#endregion + +//#region 公开 API + +export const startPausableUpload = ( + url: string, + file: File, + options?: { + onProgress?: (event: PausableUploadProgress) => void; + timeoutMs?: number; + onUnauthorized?: () => void; + } +): PausableUploadHandle => { + const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`; + const state: PausableStreamState = { + paused: false, + aborted: false, + offset: 0, + }; + const abortController = new AbortController(); + const timeoutMs = options?.timeoutMs ?? 3_600_000; + let bodyFinished = false; + + const timeoutId = window.setTimeout(() => { + state.aborted = true; + abortController.abort(); + }, timeoutMs); + + const stream = createMultipartFileStream(file, boundary, state, (sent, total) => { + if (bodyFinished) return; + + if (sent >= total) { + bodyFinished = true; + options?.onProgress?.({ phase: "finishing", percent: 99 }); + return; + } + + const sentPercent = Math.round((sent / total) * 100); + options?.onProgress?.({ + phase: "uploading", + percent: Math.min(95, sentPercent), + }); + }); + + const promise = fetch(url, { + method: "PUT", + body: stream, + credentials: "include", + signal: abortController.signal, + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }, + duplex: "half", + } as RequestInit) + .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"); + } + + 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); + throw error; + }); + + return { + promise, + pause: () => { + state.paused = true; + }, + resume: () => { + state.paused = false; + }, + abort: () => { + state.aborted = true; + abortController.abort(); + }, + }; +}; + +//#endregion diff --git a/src/pages/buckets/manage/browse/actions.tsx b/src/pages/buckets/manage/browse/actions.tsx index 9b616a5..696dac7 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -1,6 +1,5 @@ import { FolderPlus, UploadIcon } from "lucide-react"; import Button from "@/components/ui/button"; -import { usePutObject } from "./hooks"; import { toast } from "sonner"; import { handleError, openFilePicker } from "@/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; @@ -12,6 +11,8 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { InputField } from "@/components/ui/input"; import { useEffect } from "react"; +import { usePutObject } from "./hooks"; +import { useUploadQueue } from "./upload-queue"; type Props = { prefix: string; @@ -19,15 +20,7 @@ type Props = { const Actions = ({ prefix }: Props) => { const { bucketName } = useBucketContext(); - const queryClient = useQueryClient(); - - const putObject = usePutObject(bucketName, { - onSuccess: () => { - toast.success("File uploaded!"); - queryClient.invalidateQueries({ queryKey: ["browse", bucketName] }); - }, - onError: handleError, - }); + const { enqueueFiles } = useUploadQueue(); const onUploadFile = () => { openFilePicker((files) => { @@ -36,24 +29,23 @@ const Actions = ({ prefix }: Props) => { return; } - for (const file of files) { - const key = prefix + file.name; - putObject.mutate({ key, file }); - } + enqueueFiles( + bucketName, + prefix, + Array.from(files) + ); }, { multiple: true }); }; return ( <> - {/*