Merge 72dccbc38a9b3543fb125f6936095892a3d4c1a2 into ee420fbf2946e9f79977615cee5e29192d7da478

This commit is contained in:
Norman Wang 2026-06-20 16:18:15 +08:00 committed by GitHub
commit 18d831769e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 717 additions and 45 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;
}
} }

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

@ -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<T = unknown> = {
promise: Promise<T>;
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<Uint8Array>) => {
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<Uint8Array>({
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 = <T = unknown>(
url: string,
file: File,
options?: StartPausableUploadOptions
): 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 ?? 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();
},
};
};

View File

@ -29,6 +29,53 @@ export const handleError = (err: unknown) => {
toast.error((err as Error)?.message || "Unknown error"); 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) => { export const copyToClipboard = async (text: string) => {
let textArea: HTMLTextAreaElement | undefined; let textArea: HTMLTextAreaElement | undefined;

View File

@ -1,8 +1,7 @@
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 } from "@/lib/utils"; import { handleError, openFilePicker } from "@/lib/utils";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useBucketContext } from "../context"; import { useBucketContext } from "../context";
import { useDisclosure } from "@/hooks/useDisclosure"; import { useDisclosure } from "@/hooks/useDisclosure";
@ -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,53 +20,32 @@ 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 = () => {
const input = document.createElement("input"); openFilePicker((files) => {
input.type = "file";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (!files?.length) {
return;
}
if (files.length > 20) { if (files.length > 20) {
toast.error("You can only upload up to 20 files at a time"); toast.error("You can only upload up to 20 files at a time");
return; return;
} }
for (const file of files) { enqueueFiles(
const key = prefix + file.name; bucketName,
putObject.mutate({ key, file }); prefix,
} Array.from(files)
}; );
}, { multiple: true });
input.click();
input.remove();
}; };
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" /> */}
</> </>
); );
}; };
@ -99,7 +79,7 @@ const CreateFolderAction = ({ prefix }: CreateFolderActionProps) => {
}); });
const onSubmit = form.handleSubmit((values) => { const onSubmit = form.handleSubmit((values) => {
createFolder.mutate({ key: `${prefix}${values.name}/`, file: null }); createFolder.mutate({ key: `${prefix}${values.name}/` });
}); });
return ( return (

View File

@ -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 <Icon size={20} className={className} aria-hidden />;
};
export default BrowseFileTypeIcon;

View File

@ -26,13 +26,9 @@ export const usePutObject = (
options?: UseMutationOptions<any, Error, PutObjectPayload> options?: UseMutationOptions<any, Error, PutObjectPayload>
) => { ) => {
return useMutation({ return useMutation({
mutationFn: async (body) => { mutationFn: ({ key }) => {
const formData = new FormData(); const formData = new FormData();
if (body.file) { return api.put(`/browse/${bucket}/${key}`, { body: formData });
formData.append("file", body.file);
}
return api.put(`/browse/${bucket}/${body.key}`, { body: formData });
}, },
...options, ...options,
}); });

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,10 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
</tr> </tr>
) : null} ) : null}
{uploadItems.map((item) => (
<UploadRow key={item.id} item={item} />
))}
{data?.prefixes.map((prefix) => ( {data?.prefixes.map((prefix) => (
<tr <tr
key={prefix} key={prefix}
@ -106,10 +119,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,27 @@ export type Object = {
url: string; url: string;
}; };
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;
}; };

View File

@ -0,0 +1,234 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { API_URL } from "@/lib/api";
import { startPausableUpload } from "@/lib/pausable-upload";
import { url } from "@/lib/utils";
import { toUploadErrorMessage } from "./upload-status";
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 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();
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) => {
let lastEventKey = "";
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;
patchItem(id, {
percent: event.percent,
phase: event.phase as UploadRowPhase,
status: progressToStatus(event.phase as UploadRowPhase),
});
},
}
);
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();
setItems((prev) => [...prev, createUploadItem(id, bucket, prefix, file)]);
runUpload(id, bucket, prefix + file.name, 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((entry) => entry.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((entry) => entry.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,41 @@
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;
};
const UploadRowActions = ({ item }: 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,55 @@
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;
};
const fileExtension = (fileName: string) => {
const dotIndex = fileName.lastIndexOf(".");
return dotIndex >= 0 ? fileName.substring(dotIndex + 1) : null;
};
const UploadRow = ({ item }: Props) => {
const status = getUploadStatusLabel(item);
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
/>
) : (
<BrowseFileTypeIcon
ext={fileExtension(item.fileName)}
className="text-base-content/60 mr-2 shrink-0"
/>
)}
<span className="truncate min-w-0">{item.fileName}</span>
</span>
</td>
<td className="whitespace-nowrap">{readableBytes(item.size)}</td>
<td className="whitespace-nowrap" title={status}>
{status}
</td>
<UploadRowActions item={item} />
</tr>
);
};
export default UploadRow;

View File

@ -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.";
};

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>
)} )}