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/pausable-upload.ts b/src/lib/pausable-upload.ts new file mode 100644 index 0000000..c13ec23 --- /dev/null +++ b/src/lib/pausable-upload.ts @@ -0,0 +1,206 @@ +import { APIError } from "@/lib/api"; + +const CHUNK_SIZE = 512 * 1024; +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"; + +type PausableStreamState = { + paused: boolean; + aborted: boolean; + offset: number; +}; + +export type PausableUploadHandle = { + promise: Promise; + pause: () => void; + resume: () => void; + abort: () => void; +}; + +type UploadProgressEvent = { + percent: number; + phase: "uploading" | "finishing"; +}; + +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, + 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"; + + return new ReadableStream({ + async pull(controller) { + if (state.aborted) { + abortStream(controller); + return; + } + + await waitWhilePaused(state); + if (state.aborted) { + abortStream(controller); + return; + } + + if (phase === "header") { + controller.enqueue(header); + phase = "body"; + onBytesSent(0, file.size); + return; + } + + if (phase === "body") { + if (state.offset < file.size) { + await waitWhilePaused(state); + if (state.aborted) { + abortStream(controller); + 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(); + } + }, + }); +}; + +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(); + + 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?: StartPausableUploadOptions +): 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 ?? DEFAULT_UPLOAD_TIMEOUT_MS; + 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(MAX_UPLOAD_PERCENT_BEFORE_FINISH, 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); + + try { + return (await parseUploadResponse(res)) as T; + } catch (error) { + if (error instanceof APIError && error.status === 401) { + options?.onUnauthorized?.(); + } + throw error; + } + }) + .catch((error) => { + window.clearTimeout(timeoutId); + throw error; + }); + + return { + promise, + pause: () => { + state.paused = true; + }, + resume: () => { + state.paused = false; + }, + abort: () => { + state.aborted = true; + abortController.abort(); + }, + }; +}; 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..071f442 100644 --- a/src/pages/buckets/manage/browse/actions.tsx +++ b/src/pages/buckets/manage/browse/actions.tsx @@ -1,8 +1,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"; @@ -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,53 +20,32 @@ 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 = () => { - 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; } - for (const file of files) { - const key = prefix + file.name; - putObject.mutate({ key, file }); - } - }; - - input.click(); - input.remove(); + enqueueFiles( + bucketName, + prefix, + Array.from(files) + ); + }, { multiple: true }); }; return ( <> - {/*