mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-07-28 11:00:19 +07:00
refactor: tidy upload queue modules and remove dead upload API
Split status labels and file icons into focused modules, drop unused XHR upload path from api.ts, and remove Chinese region markers so the browse upload flow matches upstream style. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2e4019ac6d
commit
72dccbc38a
106
src/lib/api.ts
106
src/lib/api.ts
@ -7,25 +7,6 @@ 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 {
|
||||||
@ -106,93 +87,6 @@ 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,
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
//#region 常量/配置
|
import { APIError } from "@/lib/api";
|
||||||
|
|
||||||
const CHUNK_SIZE = 512 * 1024;
|
const CHUNK_SIZE = 512 * 1024;
|
||||||
|
const DEFAULT_UPLOAD_TIMEOUT_MS = 3_600_000;
|
||||||
//#endregion
|
const PAUSE_POLL_MS = 80;
|
||||||
|
const MAX_UPLOAD_PERCENT_BEFORE_FINISH = 95;
|
||||||
//#region 模型/类型
|
|
||||||
|
|
||||||
type StreamPhase = "header" | "body" | "footer";
|
type StreamPhase = "header" | "body" | "footer";
|
||||||
|
|
||||||
@ -14,11 +13,6 @@ type PausableStreamState = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PausableUploadProgress = {
|
|
||||||
percent: number;
|
|
||||||
phase: "uploading" | "finishing";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PausableUploadHandle<T = unknown> = {
|
export type PausableUploadHandle<T = unknown> = {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
@ -26,13 +20,30 @@ export type PausableUploadHandle<T = unknown> = {
|
|||||||
abort: () => void;
|
abort: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
//#endregion
|
type UploadProgressEvent = {
|
||||||
|
percent: number;
|
||||||
|
phase: "uploading" | "finishing";
|
||||||
|
};
|
||||||
|
|
||||||
//#region 方法/工具
|
type StartPausableUploadOptions = {
|
||||||
|
onProgress?: (event: UploadProgressEvent) => void;
|
||||||
|
timeoutMs?: number;
|
||||||
|
onUnauthorized?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const escapeMultipartFilename = (name: string) =>
|
const escapeMultipartFilename = (name: string) =>
|
||||||
name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
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 = (
|
const createMultipartFileStream = (
|
||||||
file: File,
|
file: File,
|
||||||
boundary: string,
|
boundary: string,
|
||||||
@ -48,23 +59,16 @@ const createMultipartFileStream = (
|
|||||||
const footer = encoder.encode(`\r\n--${boundary}--\r\n`);
|
const footer = encoder.encode(`\r\n--${boundary}--\r\n`);
|
||||||
let phase: StreamPhase = "header";
|
let phase: StreamPhase = "header";
|
||||||
|
|
||||||
const waitWhilePaused = async () => {
|
|
||||||
while (state.paused && !state.aborted) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ReadableStream<Uint8Array>({
|
return new ReadableStream<Uint8Array>({
|
||||||
async pull(controller) {
|
async pull(controller) {
|
||||||
if (state.aborted) {
|
if (state.aborted) {
|
||||||
controller.error(new DOMException("Upload aborted", "AbortError"));
|
abortStream(controller);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitWhilePaused();
|
await waitWhilePaused(state);
|
||||||
|
|
||||||
if (state.aborted) {
|
if (state.aborted) {
|
||||||
controller.error(new DOMException("Upload aborted", "AbortError"));
|
abortStream(controller);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,9 +81,9 @@ const createMultipartFileStream = (
|
|||||||
|
|
||||||
if (phase === "body") {
|
if (phase === "body") {
|
||||||
if (state.offset < file.size) {
|
if (state.offset < file.size) {
|
||||||
await waitWhilePaused();
|
await waitWhilePaused(state);
|
||||||
if (state.aborted) {
|
if (state.aborted) {
|
||||||
controller.error(new DOMException("Upload aborted", "AbortError"));
|
abortStream(controller);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,18 +106,31 @@ const createMultipartFileStream = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
//#endregion
|
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();
|
||||||
|
|
||||||
//#region 公开 API
|
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>(
|
export const startPausableUpload = <T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
file: File,
|
file: File,
|
||||||
options?: {
|
options?: StartPausableUploadOptions
|
||||||
onProgress?: (event: PausableUploadProgress) => void;
|
|
||||||
timeoutMs?: number;
|
|
||||||
onUnauthorized?: () => void;
|
|
||||||
}
|
|
||||||
): PausableUploadHandle<T> => {
|
): PausableUploadHandle<T> => {
|
||||||
const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`;
|
const boundary = `----GarageUpload${Math.random().toString(36).slice(2, 12)}`;
|
||||||
const state: PausableStreamState = {
|
const state: PausableStreamState = {
|
||||||
@ -122,7 +139,7 @@ export const startPausableUpload = <T = unknown>(
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutMs = options?.timeoutMs ?? 3_600_000;
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS;
|
||||||
let bodyFinished = false;
|
let bodyFinished = false;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
@ -142,7 +159,7 @@ export const startPausableUpload = <T = unknown>(
|
|||||||
const sentPercent = Math.round((sent / total) * 100);
|
const sentPercent = Math.round((sent / total) * 100);
|
||||||
options?.onProgress?.({
|
options?.onProgress?.({
|
||||||
phase: "uploading",
|
phase: "uploading",
|
||||||
percent: Math.min(95, sentPercent),
|
percent: Math.min(MAX_UPLOAD_PERCENT_BEFORE_FINISH, sentPercent),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -159,25 +176,14 @@ export const startPausableUpload = <T = unknown>(
|
|||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
|
|
||||||
const contentType = res.headers.get("Content-Type") || "";
|
try {
|
||||||
const isJson = contentType.includes("application/json");
|
return (await parseUploadResponse(res)) as T;
|
||||||
const data = isJson ? await res.json() : await res.text();
|
} catch (error) {
|
||||||
|
if (error instanceof APIError && error.status === 401) {
|
||||||
if (res.status === 401) {
|
|
||||||
options?.onUnauthorized?.();
|
options?.onUnauthorized?.();
|
||||||
throw new Error("unauthorized");
|
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
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) => {
|
.catch((error) => {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
@ -198,5 +204,3 @@ export const startPausableUpload = <T = unknown>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
//#endregion
|
|
||||||
|
|||||||
@ -79,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 (
|
||||||
|
|||||||
39
src/pages/buckets/manage/browse/file-type-icon.tsx
Normal file
39
src/pages/buckets/manage/browse/file-type-icon.tsx
Normal 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;
|
||||||
@ -26,17 +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);
|
|
||||||
const handle = api.upload(`/browse/${bucket}/${body.key}`, formData, {
|
|
||||||
onProgress: body.onProgress,
|
|
||||||
});
|
|
||||||
return handle.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
return api.put(`/browse/${bucket}/${body.key}`, { body: formData });
|
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -74,14 +74,8 @@ const ObjectList = ({ prefix, onPrefixChange }: Props) => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{uploadItems.map((item, idx) => (
|
{uploadItems.map((item) => (
|
||||||
<UploadRow
|
<UploadRow key={item.id} item={item} />
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
end={
|
|
||||||
idx >= uploadItems.length - 2 && uploadItems.length > 5
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{data?.prefixes.map((prefix) => (
|
{data?.prefixes.map((prefix) => (
|
||||||
|
|||||||
@ -18,11 +18,6 @@ export type Object = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadProgressEvent = {
|
|
||||||
percent: number;
|
|
||||||
phase: "uploading" | "finishing";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UploadRowPhase = "uploading" | "finishing" | "paused";
|
export type UploadRowPhase = "uploading" | "finishing" | "paused";
|
||||||
|
|
||||||
export type UploadRowStatus =
|
export type UploadRowStatus =
|
||||||
@ -46,6 +41,4 @@ export type UploadRowItem = {
|
|||||||
|
|
||||||
export type PutObjectPayload = {
|
export type PutObjectPayload = {
|
||||||
key: string;
|
key: string;
|
||||||
file: File | null;
|
|
||||||
onProgress?: (event: UploadProgressEvent) => void;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,9 +8,10 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { APIError, API_URL } from "@/lib/api";
|
import { API_URL } from "@/lib/api";
|
||||||
import { startPausableUpload } from "@/lib/pausable-upload";
|
import { startPausableUpload } from "@/lib/pausable-upload";
|
||||||
import * as utils from "@/lib/utils";
|
import { url } from "@/lib/utils";
|
||||||
|
import { toUploadErrorMessage } from "./upload-status";
|
||||||
import {
|
import {
|
||||||
UploadRowItem,
|
UploadRowItem,
|
||||||
UploadRowPhase,
|
UploadRowPhase,
|
||||||
@ -39,19 +40,25 @@ const createUploadId = () =>
|
|||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
const toUploadErrorMessage = (error: unknown) => {
|
const progressToStatus = (phase: UploadRowPhase): UploadRowStatus =>
|
||||||
if (error instanceof DOMException && error.name === "AbortError") return null;
|
phase === "finishing" ? "finishing" : "uploading";
|
||||||
if (error instanceof APIError) {
|
|
||||||
if (error.status === 0) return "Network error. Check your connection.";
|
const createUploadItem = (
|
||||||
if (error.status === 413) return "File exceeds the server upload size limit.";
|
id: string,
|
||||||
if (error.status === 502 || error.status === 504) {
|
bucket: string,
|
||||||
return "Server timed out while saving. Retry or use the S3 API for large files.";
|
prefix: string,
|
||||||
}
|
file: File
|
||||||
if (error.message) return error.message;
|
): UploadRowItem => ({
|
||||||
}
|
id,
|
||||||
if (error instanceof Error && error.message) return error.message;
|
bucket,
|
||||||
return "Upload failed.";
|
prefix,
|
||||||
};
|
key: prefix + file.name,
|
||||||
|
fileName: file.name,
|
||||||
|
size: file.size,
|
||||||
|
percent: 0,
|
||||||
|
phase: "uploading",
|
||||||
|
status: "uploading",
|
||||||
|
});
|
||||||
|
|
||||||
export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -76,28 +83,28 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const runUpload = useCallback(
|
const runUpload = useCallback(
|
||||||
(id: string, bucket: string, key: string, file: File) => {
|
(id: string, bucket: string, key: string, file: File) => {
|
||||||
const url = `${API_URL}/browse/${bucket}/${key}`;
|
|
||||||
let lastEventKey = "";
|
let lastEventKey = "";
|
||||||
|
|
||||||
const handle = startPausableUpload(url, file, {
|
const handle = startPausableUpload(
|
||||||
|
`${API_URL}/browse/${bucket}/${key}`,
|
||||||
|
file,
|
||||||
|
{
|
||||||
onUnauthorized: () => {
|
onUnauthorized: () => {
|
||||||
window.location.href = utils.url("/auth/login");
|
window.location.href = url("/auth/login");
|
||||||
},
|
},
|
||||||
onProgress: (event) => {
|
onProgress: (event) => {
|
||||||
const eventKey = `${event.phase}:${event.percent}`;
|
const eventKey = `${event.phase}:${event.percent}`;
|
||||||
if (eventKey === lastEventKey) return;
|
if (eventKey === lastEventKey) return;
|
||||||
lastEventKey = eventKey;
|
lastEventKey = eventKey;
|
||||||
|
|
||||||
const status: UploadRowStatus =
|
|
||||||
event.phase === "finishing" ? "finishing" : "uploading";
|
|
||||||
|
|
||||||
patchItem(id, {
|
patchItem(id, {
|
||||||
percent: event.percent,
|
percent: event.percent,
|
||||||
phase: event.phase as UploadRowPhase,
|
phase: event.phase as UploadRowPhase,
|
||||||
status,
|
status: progressToStatus(event.phase as UploadRowPhase),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
activeRef.current.set(id, {
|
activeRef.current.set(id, {
|
||||||
pause: handle.pause,
|
pause: handle.pause,
|
||||||
@ -121,10 +128,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const message = toUploadErrorMessage(error);
|
const message = toUploadErrorMessage(error);
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
patchItem(id, {
|
patchItem(id, { status: "error", errorMessage: message });
|
||||||
status: "error",
|
|
||||||
errorMessage: message,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[patchItem, queryClient, removeItem]
|
[patchItem, queryClient, removeItem]
|
||||||
@ -134,24 +138,8 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
(bucket: string, prefix: string, files: File[]) => {
|
(bucket: string, prefix: string, files: File[]) => {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const id = createUploadId();
|
const id = createUploadId();
|
||||||
const key = prefix + file.name;
|
setItems((prev) => [...prev, createUploadItem(id, bucket, prefix, file)]);
|
||||||
|
runUpload(id, bucket, prefix + file.name, file);
|
||||||
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]
|
[runUpload]
|
||||||
@ -163,10 +151,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
active.pause();
|
active.pause();
|
||||||
patchItem(id, {
|
patchItem(id, { status: "paused", phase: "paused" });
|
||||||
status: "paused",
|
|
||||||
phase: "paused",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[patchItem]
|
[patchItem]
|
||||||
);
|
);
|
||||||
@ -174,7 +159,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const resumeUpload = useCallback(
|
const resumeUpload = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const active = activeRef.current.get(id);
|
const active = activeRef.current.get(id);
|
||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((entry) => entry.id === id);
|
||||||
if (!active || !item || item.status !== "paused") return;
|
if (!active || !item || item.status !== "paused") return;
|
||||||
|
|
||||||
patchItem(id, {
|
patchItem(id, {
|
||||||
@ -189,7 +174,7 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const cancelUpload = useCallback(
|
const cancelUpload = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((entry) => entry.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -202,11 +187,8 @@ export const UploadQueueProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
cancelledRef.current.add(id);
|
cancelledRef.current.add(id);
|
||||||
const active = activeRef.current.get(id);
|
const active = activeRef.current.get(id);
|
||||||
if (active) {
|
if (active) active.abort();
|
||||||
active.abort();
|
else removeItem(id);
|
||||||
} else {
|
|
||||||
removeItem(id);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[items, removeItem]
|
[items, removeItem]
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,10 +5,9 @@ import { useUploadQueue } from "./upload-queue";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: UploadRowItem;
|
item: UploadRowItem;
|
||||||
end?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const UploadRowActions = ({ item, end: _end }: Props) => {
|
const UploadRowActions = ({ item }: Props) => {
|
||||||
const { pauseUpload, resumeUpload, cancelUpload } = useUploadQueue();
|
const { pauseUpload, resumeUpload, cancelUpload } = useUploadQueue();
|
||||||
const isPaused = item.status === "paused";
|
const isPaused = item.status === "paused";
|
||||||
const isActive = item.status === "uploading" || item.status === "finishing";
|
const isActive = item.status === "uploading" || item.status === "finishing";
|
||||||
|
|||||||
@ -1,26 +1,21 @@
|
|||||||
import { AlertCircle, FileArchive, FileIcon, FileType } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import mime from "mime/lite";
|
|
||||||
import { readableBytes } from "@/lib/utils";
|
import { readableBytes } from "@/lib/utils";
|
||||||
|
import BrowseFileTypeIcon from "./file-type-icon";
|
||||||
|
import { getUploadStatusLabel } from "./upload-status";
|
||||||
import { UploadRowItem } from "./types";
|
import { UploadRowItem } from "./types";
|
||||||
import UploadRowActions from "./upload-row-actions";
|
import UploadRowActions from "./upload-row-actions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: UploadRowItem;
|
item: UploadRowItem;
|
||||||
end?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel = (item: UploadRowItem) => {
|
const fileExtension = (fileName: string) => {
|
||||||
if (item.status === "error") return item.errorMessage ?? "Upload failed";
|
const dotIndex = fileName.lastIndexOf(".");
|
||||||
if (item.status === "paused") return `Paused at ${item.percent}%`;
|
return dotIndex >= 0 ? fileName.substring(dotIndex + 1) : null;
|
||||||
if (item.status === "finishing") return "Saving to storage…";
|
|
||||||
return `Uploading — ${item.percent}%`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const UploadRow = ({ item, end }: Props) => {
|
const UploadRow = ({ item }: Props) => {
|
||||||
const extIdx = item.fileName.lastIndexOf(".");
|
const status = getUploadStatusLabel(item);
|
||||||
const extBare =
|
|
||||||
extIdx >= 0 ? item.fileName.substring(extIdx + 1) : null;
|
|
||||||
|
|
||||||
const isError = item.status === "error";
|
const isError = item.status === "error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -37,7 +32,10 @@ const UploadRow = ({ item, end }: Props) => {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UploadFileIcon ext={extBare} />
|
<BrowseFileTypeIcon
|
||||||
|
ext={fileExtension(item.fileName)}
|
||||||
|
className="text-base-content/60 mr-2 shrink-0"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="truncate min-w-0">{item.fileName}</span>
|
<span className="truncate min-w-0">{item.fileName}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -45,38 +43,13 @@ const UploadRow = ({ item, end }: Props) => {
|
|||||||
|
|
||||||
<td className="whitespace-nowrap">{readableBytes(item.size)}</td>
|
<td className="whitespace-nowrap">{readableBytes(item.size)}</td>
|
||||||
|
|
||||||
<td className="whitespace-nowrap" title={statusLabel(item)}>
|
<td className="whitespace-nowrap" title={status}>
|
||||||
{statusLabel(item)}
|
{status}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<UploadRowActions item={item} end={end} />
|
<UploadRowActions item={item} />
|
||||||
</tr>
|
</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;
|
export default UploadRow;
|
||||||
|
|||||||
25
src/pages/buckets/manage/browse/upload-status.ts
Normal file
25
src/pages/buckets/manage/browse/upload-status.ts
Normal 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.";
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user