mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-07-28 11:00:19 +07:00
Merge 72dccbc38a9b3543fb125f6936095892a3d4c1a2 into ee420fbf2946e9f79977615cee5e29192d7da478
This commit is contained in:
commit
18d831769e
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
206
src/lib/pausable-upload.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
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,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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
234
src/pages/buckets/manage/browse/upload-queue.tsx
Normal file
234
src/pages/buckets/manage/browse/upload-queue.tsx
Normal 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;
|
||||||
|
};
|
||||||
41
src/pages/buckets/manage/browse/upload-row-actions.tsx
Normal file
41
src/pages/buckets/manage/browse/upload-row-actions.tsx
Normal 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;
|
||||||
55
src/pages/buckets/manage/browse/upload-row.tsx
Normal file
55
src/pages/buckets/manage/browse/upload-row.tsx
Normal 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;
|
||||||
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.";
|
||||||
|
};
|
||||||
@ -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 || "" }}
|
||||||
>
|
>
|
||||||
|
<UploadQueueProvider>
|
||||||
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
||||||
|
</UploadQueueProvider>
|
||||||
</BucketContext.Provider>
|
</BucketContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user