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 <cursoragent@cursor.com>
This commit is contained in:
Norman-W 2026-06-20 16:02:50 +08:00
parent 2e1a9d078f
commit 2e4019ac6d
12 changed files with 764 additions and 22 deletions

View File

@ -14,7 +14,11 @@ const App = () => {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Router /> <Router />
</QueryClientProvider> </QueryClientProvider>
<Toaster richColors /> <Toaster
expand={false}
gap={10}
toastOptions={{ className: "garage-toast-host" }}
/>
<ThemeProvider /> <ThemeProvider />
</PageContextProvider> </PageContextProvider>
); );

View File

@ -36,4 +36,9 @@
.modal-backdrop { .modal-backdrop {
@apply border-none outline-none; @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;
}
} }

View File

@ -7,6 +7,25 @@ type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
body?: any; body?: any;
}; };
type UploadProgressEvent = {
percent: number;
phase: "uploading" | "finishing";
};
type UploadOptions = {
params?: Record<string, any>;
onProgress?: (event: UploadProgressEvent) => void;
timeoutMs?: number;
};
export type UploadHandle<T = unknown> = {
promise: Promise<T>;
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 const API_URL = BASE_PATH + "/api";
export class APIError extends Error { export class APIError extends Error {
@ -87,6 +106,93 @@ const api = {
}); });
}, },
upload<T = any>(
url: string,
body: FormData,
options?: UploadOptions
): UploadHandle<T> {
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<T>((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<T = any>(url: string, options?: Partial<FetchOptions>) { async delete<T = any>(url: string, options?: Partial<FetchOptions>) {
return this.fetch<T>(url, { return this.fetch<T>(url, {
...options, ...options,

202
src/lib/pausable-upload.ts Normal file
View File

@ -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<T = unknown> = {
promise: Promise<T>;
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<Uint8Array>({
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 = <T = unknown>(
url: string,
file: File,
options?: {
onProgress?: (event: PausableUploadProgress) => void;
timeoutMs?: number;
onUnauthorized?: () => void;
}
): PausableUploadHandle<T> => {
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

View File

@ -1,6 +1,5 @@
import { FolderPlus, UploadIcon } from "lucide-react"; import { FolderPlus, UploadIcon } from "lucide-react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { usePutObject } from "./hooks";
import { toast } from "sonner"; import { toast } from "sonner";
import { handleError, openFilePicker } from "@/lib/utils"; import { handleError, openFilePicker } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@ -12,6 +11,8 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InputField } from "@/components/ui/input"; import { InputField } from "@/components/ui/input";
import { useEffect } from "react"; import { useEffect } from "react";
import { usePutObject } from "./hooks";
import { useUploadQueue } from "./upload-queue";
type Props = { type Props = {
prefix: string; prefix: string;
@ -19,15 +20,7 @@ type Props = {
const Actions = ({ prefix }: Props) => { const Actions = ({ prefix }: Props) => {
const { bucketName } = useBucketContext(); const { bucketName } = useBucketContext();
const queryClient = useQueryClient(); const { enqueueFiles } = useUploadQueue();
const putObject = usePutObject(bucketName, {
onSuccess: () => {
toast.success("File uploaded!");
queryClient.invalidateQueries({ queryKey: ["browse", bucketName] });
},
onError: handleError,
});
const onUploadFile = () => { const onUploadFile = () => {
openFilePicker((files) => { openFilePicker((files) => {
@ -36,24 +29,23 @@ const Actions = ({ prefix }: Props) => {
return; return;
} }
for (const file of files) { enqueueFiles(
const key = prefix + file.name; bucketName,
putObject.mutate({ key, file }); prefix,
} Array.from(files)
);
}, { multiple: true }); }, { multiple: true });
}; };
return ( return (
<> <>
<CreateFolderAction prefix={prefix} /> <CreateFolderAction prefix={prefix} />
{/* <Button icon={FilePlus} color="ghost" /> */}
<Button <Button
icon={UploadIcon} icon={UploadIcon}
color="ghost" color="ghost"
title="Upload File" title="Upload File"
onClick={onUploadFile} onClick={onUploadFile}
/> />
{/* <Button icon={EllipsisVertical} color="ghost" /> */}
</> </>
); );
}; };

View File

@ -30,6 +30,10 @@ export const usePutObject = (
const formData = new FormData(); const formData = new FormData();
if (body.file) { if (body.file) {
formData.append("file", 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}/${body.key}`, { body: formData });

View File

@ -14,6 +14,8 @@ import {
import { useBucketContext } from "../context"; import { useBucketContext } from "../context";
import ObjectActions from "./object-actions"; import ObjectActions from "./object-actions";
import GotoTopButton from "@/components/ui/goto-top-btn"; import GotoTopButton from "@/components/ui/goto-top-btn";
import { useUploadQueue } from "./upload-queue";
import UploadRow from "./upload-row";
type Props = { type Props = {
prefix?: string; prefix?: string;
@ -22,11 +24,18 @@ type Props = {
const ObjectList = ({ prefix, onPrefixChange }: Props) => { const ObjectList = ({ prefix, onPrefixChange }: Props) => {
const { bucketName } = useBucketContext(); const { bucketName } = useBucketContext();
const { getItemsForLocation } = useUploadQueue();
const uploadItems = getItemsForLocation(bucketName, prefix || "");
const { data, error, isLoading } = useBrowseObjects(bucketName, { const { data, error, isLoading } = useBrowseObjects(bucketName, {
prefix, prefix,
limit: 1000, limit: 1000,
}); });
const hasServerObjects =
(data?.prefixes?.length ?? 0) > 0 || (data?.objects?.length ?? 0) > 0;
const showEmpty = !isLoading && !error && !hasServerObjects && uploadItems.length === 0;
const onObjectClick = (object: Object) => { const onObjectClick = (object: Object) => {
window.open(API_URL + object.url + "?view=1", "_blank"); window.open(API_URL + object.url + "?view=1", "_blank");
}; };
@ -57,7 +66,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</Alert> </Alert>
</td> </td>
</tr> </tr>
) : !data?.prefixes?.length && !data?.objects?.length ? ( ) : showEmpty ? (
<tr> <tr>
<td className="text-center py-16" colSpan={3}> <td className="text-center py-16" colSpan={3}>
No objects No objects
@ -65,6 +74,16 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</tr> </tr>
) : null} ) : null}
{uploadItems.map((item, idx) => (
<UploadRow
key={item.id}
item={item}
end={
idx >= uploadItems.length - 2 && uploadItems.length > 5
}
/>
))}
{data?.prefixes.map((prefix) => ( {data?.prefixes.map((prefix) => (
<tr <tr
key={prefix} key={prefix}
@ -106,10 +125,14 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
role="button" role="button"
onClick={() => onObjectClick(object)} onClick={() => onObjectClick(object)}
> >
<span className="flex items-center font-normal w-full"> <span className="flex items-center font-normal w-full min-w-0">
<FilePreview ext={ext?.substring(1)} object={object} /> <FilePreview ext={ext?.substring(1)} object={object} />
<span className="truncate max-w-[40vw]">{filename}</span> <span className="truncate min-w-0" title={object.objectKey}>
{ext && <span className="text-base-content/60">{ext}</span>} {filename}
</span>
{ext && (
<span className="text-base-content/60 shrink-0">{ext}</span>
)}
</span> </span>
</td> </td>
<td className="whitespace-nowrap"> <td className="whitespace-nowrap">

View File

@ -18,7 +18,34 @@ export type Object = {
url: string; url: string;
}; };
export type UploadProgressEvent = {
percent: number;
phase: "uploading" | "finishing";
};
export type UploadRowPhase = "uploading" | "finishing" | "paused";
export type UploadRowStatus =
| "uploading"
| "paused"
| "finishing"
| "error";
export type UploadRowItem = {
id: string;
bucket: string;
prefix: string;
key: string;
fileName: string;
size: number;
percent: number;
phase: UploadRowPhase;
status: UploadRowStatus;
errorMessage?: string;
};
export type PutObjectPayload = { export type PutObjectPayload = {
key: string; key: string;
file: File | null; file: File | null;
onProgress?: (event: UploadProgressEvent) => void;
}; };

View File

@ -0,0 +1,252 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { APIError, API_URL } from "@/lib/api";
import { startPausableUpload } from "@/lib/pausable-upload";
import * as utils from "@/lib/utils";
import {
UploadRowItem,
UploadRowPhase,
UploadRowStatus,
} from "./types";
type ActiveUpload = {
pause: () => void;
resume: () => void;
abort: () => void;
};
type UploadQueueContextValue = {
items: UploadRowItem[];
enqueueFiles: (bucket: string, prefix: string, files: File[]) => void;
pauseUpload: (id: string) => void;
resumeUpload: (id: string) => void;
cancelUpload: (id: string) => void;
getItemsForLocation: (bucket: string, prefix: string) => UploadRowItem[];
};
const UploadQueueContext = createContext<UploadQueueContextValue | null>(null);
const createUploadId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? 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.";
};
export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
const queryClient = useQueryClient();
const [items, setItems] = useState<UploadRowItem[]>([]);
const activeRef = useRef<Map<string, ActiveUpload>>(new Map());
const cancelledRef = useRef<Set<string>>(new Set());
const patchItem = useCallback(
(id: string, patch: Partial<UploadRowItem>) => {
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch } : item))
);
},
[]
);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
activeRef.current.delete(id);
cancelledRef.current.delete(id);
}, []);
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 status: UploadRowStatus =
event.phase === "finishing" ? "finishing" : "uploading";
patchItem(id, {
percent: event.percent,
phase: event.phase as UploadRowPhase,
status,
});
},
});
activeRef.current.set(id, {
pause: handle.pause,
resume: handle.resume,
abort: handle.abort,
});
handle.promise
.then(() => {
removeItem(id);
queryClient.invalidateQueries({ queryKey: ["browse", bucket] });
})
.catch((error) => {
activeRef.current.delete(id);
if (cancelledRef.current.has(id)) {
removeItem(id);
return;
}
const message = toUploadErrorMessage(error);
if (!message) return;
patchItem(id, {
status: "error",
errorMessage: message,
});
});
},
[patchItem, queryClient, removeItem]
);
const enqueueFiles = useCallback(
(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);
}
},
[runUpload]
);
const pauseUpload = useCallback(
(id: string) => {
const active = activeRef.current.get(id);
if (!active) return;
active.pause();
patchItem(id, {
status: "paused",
phase: "paused",
});
},
[patchItem]
);
const resumeUpload = useCallback(
(id: string) => {
const active = activeRef.current.get(id);
const item = items.find((i) => i.id === id);
if (!active || !item || item.status !== "paused") return;
patchItem(id, {
status: "uploading",
phase: "uploading",
errorMessage: undefined,
});
active.resume();
},
[items, patchItem]
);
const cancelUpload = useCallback(
(id: string) => {
const item = items.find((i) => i.id === id);
if (!item) return;
if (
!window.confirm(
`Cancel upload and remove "${item.fileName}" from the list?`
)
) {
return;
}
cancelledRef.current.add(id);
const active = activeRef.current.get(id);
if (active) {
active.abort();
} else {
removeItem(id);
}
},
[items, removeItem]
);
const getItemsForLocation = useCallback(
(bucket: string, prefix: string) =>
items.filter((item) => item.bucket === bucket && item.prefix === prefix),
[items]
);
const value = useMemo(
() => ({
items,
enqueueFiles,
pauseUpload,
resumeUpload,
cancelUpload,
getItemsForLocation,
}),
[
items,
enqueueFiles,
pauseUpload,
resumeUpload,
cancelUpload,
getItemsForLocation,
]
);
return (
<UploadQueueContext.Provider value={value}>
{children}
</UploadQueueContext.Provider>
);
};
export const useUploadQueue = () => {
const ctx = useContext(UploadQueueContext);
if (!ctx) {
throw new Error("useUploadQueue must be used within UploadQueueProvider");
}
return ctx;
};

View File

@ -0,0 +1,42 @@
import Button from "@/components/ui/button";
import { PauseIcon, PlayIcon, XCircle } from "lucide-react";
import { UploadRowItem } from "./types";
import { useUploadQueue } from "./upload-queue";
type Props = {
item: UploadRowItem;
end?: boolean;
};
const UploadRowActions = ({ item, end: _end }: Props) => {
const { pauseUpload, resumeUpload, cancelUpload } = useUploadQueue();
const isPaused = item.status === "paused";
const isActive = item.status === "uploading" || item.status === "finishing";
return (
<td className="!p-0 w-auto">
<span className="w-full flex flex-row justify-end pr-2 gap-0.5">
{(isActive || isPaused) && (
<Button
icon={isPaused ? PlayIcon : PauseIcon}
color="ghost"
title={isPaused ? "Resume upload" : "Pause upload"}
onClick={() =>
isPaused ? resumeUpload(item.id) : pauseUpload(item.id)
}
/>
)}
<Button
icon={XCircle}
color="ghost"
className="text-error hover:text-error"
title="Cancel upload"
onClick={() => cancelUpload(item.id)}
/>
</span>
</td>
);
};
export default UploadRowActions;

View File

@ -0,0 +1,82 @@
import { AlertCircle, FileArchive, FileIcon, FileType } from "lucide-react";
import mime from "mime/lite";
import { readableBytes } from "@/lib/utils";
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 UploadRow = ({ item, end }: Props) => {
const extIdx = item.fileName.lastIndexOf(".");
const extBare =
extIdx >= 0 ? item.fileName.substring(extIdx + 1) : null;
const isError = item.status === "error";
return (
<tr className="text-base-content/90">
<td className="cursor-default">
<span
className="flex items-center font-normal w-full min-w-0"
title={item.fileName}
>
{isError ? (
<AlertCircle
size={20}
className="text-error mr-2 shrink-0"
aria-hidden
/>
) : (
<UploadFileIcon ext={extBare} />
)}
<span className="truncate min-w-0">{item.fileName}</span>
</span>
</td>
<td className="whitespace-nowrap">{readableBytes(item.size)}</td>
<td className="whitespace-nowrap" title={statusLabel(item)}>
{statusLabel(item)}
</td>
<UploadRowActions item={item} end={end} />
</tr>
);
};
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 (
<Icon
size={20}
className="text-base-content/60 mr-2 shrink-0"
aria-hidden
/>
);
};
export default UploadRow;

View File

@ -13,6 +13,7 @@ import PermissionsTab from "./permissions/permissions-tab";
import MenuButton from "./components/menu-button"; import MenuButton from "./components/menu-button";
import BrowseTab from "./browse/browse-tab"; import BrowseTab from "./browse/browse-tab";
import { BucketContext } from "./context"; import { BucketContext } from "./context";
import { UploadQueueProvider } from "./browse/upload-queue";
import { Alert, Loading } from "react-daisyui"; import { Alert, Loading } from "react-daisyui";
const tabs: Tab[] = [ const tabs: Tab[] = [
@ -67,7 +68,9 @@ const ManageBucketPage = () => {
<BucketContext.Provider <BucketContext.Provider
value={{ bucket: data, refetch, bucketName: name || "" }} value={{ bucket: data, refetch, bucketName: name || "" }}
> >
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" /> <UploadQueueProvider>
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
</UploadQueueProvider>
</BucketContext.Provider> </BucketContext.Provider>
</div> </div>
)} )}