mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-07-28 11:00:19 +07:00
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:
parent
2e1a9d078f
commit
2e4019ac6d
@ -14,7 +14,11 @@ const App = () => {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router />
|
||||
</QueryClientProvider>
|
||||
<Toaster richColors />
|
||||
<Toaster
|
||||
expand={false}
|
||||
gap={10}
|
||||
toastOptions={{ className: "garage-toast-host" }}
|
||||
/>
|
||||
<ThemeProvider />
|
||||
</PageContextProvider>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
106
src/lib/api.ts
106
src/lib/api.ts
@ -7,6 +7,25 @@ type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
|
||||
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 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>) {
|
||||
return this.fetch<T>(url, {
|
||||
...options,
|
||||
|
||||
202
src/lib/pausable-upload.ts
Normal file
202
src/lib/pausable-upload.ts
Normal 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
|
||||
@ -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 (
|
||||
<>
|
||||
<CreateFolderAction prefix={prefix} />
|
||||
{/* <Button icon={FilePlus} color="ghost" /> */}
|
||||
<Button
|
||||
icon={UploadIcon}
|
||||
color="ghost"
|
||||
title="Upload File"
|
||||
onClick={onUploadFile}
|
||||
/>
|
||||
{/* <Button icon={EllipsisVertical} color="ghost" /> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -30,6 +30,10 @@ export const usePutObject = (
|
||||
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 });
|
||||
|
||||
@ -14,6 +14,8 @@ import {
|
||||
import { useBucketContext } from "../context";
|
||||
import ObjectActions from "./object-actions";
|
||||
import GotoTopButton from "@/components/ui/goto-top-btn";
|
||||
import { useUploadQueue } from "./upload-queue";
|
||||
import UploadRow from "./upload-row";
|
||||
|
||||
type Props = {
|
||||
prefix?: string;
|
||||
@ -22,11 +24,18 @@ type Props = {
|
||||
|
||||
const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
||||
const { bucketName } = useBucketContext();
|
||||
const { getItemsForLocation } = useUploadQueue();
|
||||
const uploadItems = getItemsForLocation(bucketName, prefix || "");
|
||||
|
||||
const { data, error, isLoading } = useBrowseObjects(bucketName, {
|
||||
prefix,
|
||||
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) => {
|
||||
window.open(API_URL + object.url + "?view=1", "_blank");
|
||||
};
|
||||
@ -57,7 +66,7 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
||||
</Alert>
|
||||
</td>
|
||||
</tr>
|
||||
) : !data?.prefixes?.length && !data?.objects?.length ? (
|
||||
) : showEmpty ? (
|
||||
<tr>
|
||||
<td className="text-center py-16" colSpan={3}>
|
||||
No objects
|
||||
@ -65,6 +74,16 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
||||
</tr>
|
||||
) : null}
|
||||
|
||||
{uploadItems.map((item, idx) => (
|
||||
<UploadRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
end={
|
||||
idx >= uploadItems.length - 2 && uploadItems.length > 5
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{data?.prefixes.map((prefix) => (
|
||||
<tr
|
||||
key={prefix}
|
||||
@ -106,10 +125,14 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
||||
role="button"
|
||||
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} />
|
||||
<span className="truncate max-w-[40vw]">{filename}</span>
|
||||
{ext && <span className="text-base-content/60">{ext}</span>}
|
||||
<span className="truncate min-w-0" title={object.objectKey}>
|
||||
{filename}
|
||||
</span>
|
||||
{ext && (
|
||||
<span className="text-base-content/60 shrink-0">{ext}</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
|
||||
@ -18,7 +18,34 @@ export type Object = {
|
||||
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 = {
|
||||
key: string;
|
||||
file: File | null;
|
||||
onProgress?: (event: UploadProgressEvent) => void;
|
||||
};
|
||||
|
||||
252
src/pages/buckets/manage/browse/upload-queue.tsx
Normal file
252
src/pages/buckets/manage/browse/upload-queue.tsx
Normal 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;
|
||||
};
|
||||
42
src/pages/buckets/manage/browse/upload-row-actions.tsx
Normal file
42
src/pages/buckets/manage/browse/upload-row-actions.tsx
Normal 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;
|
||||
82
src/pages/buckets/manage/browse/upload-row.tsx
Normal file
82
src/pages/buckets/manage/browse/upload-row.tsx
Normal 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;
|
||||
@ -13,6 +13,7 @@ import PermissionsTab from "./permissions/permissions-tab";
|
||||
import MenuButton from "./components/menu-button";
|
||||
import BrowseTab from "./browse/browse-tab";
|
||||
import { BucketContext } from "./context";
|
||||
import { UploadQueueProvider } from "./browse/upload-queue";
|
||||
import { Alert, Loading } from "react-daisyui";
|
||||
|
||||
const tabs: Tab[] = [
|
||||
@ -67,7 +68,9 @@ const ManageBucketPage = () => {
|
||||
<BucketContext.Provider
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user