codex-marketplace-ui-compon.../components/Upload/UploadFile.tsx

399 lines
9.4 KiB
TypeScript

import { useRef, useState, useReducer, Reducer, useEffect } from "react";
import { attributes } from "../../utils/attributes";
import { PrettyBytes } from "../../utils/bytes";
import { Toast } from "../Toast/Toast";
import { UploadStatus } from "./types";
import {
CircleCheck,
TriangleAlert,
CircleX,
CircleStop,
Info,
} from "lucide-react";
import { Spinner } from "../Spinner/Spinner";
import React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CodexData } from "@codex/sdk-js";
import { WebFileIcon } from "../WebFileIcon/WebFileIcon";
import { ButtonIcon } from "../ButtonIcon/ButtonIcon";
import { SimpleText } from "../SimpleText/SimpleText";
type UploadFileProps = {
file: File;
onClose: (id: string) => void;
id: string;
onSuccess: ((cid: string) => void) | undefined;
provider: () => Promise<CodexData["upload"]>;
useWorker: boolean;
};
type State = {
progress: { loaded: number; total: number };
cid: string;
preview: string;
status: UploadStatus;
error: string;
};
type Action =
| {
type: "reset";
}
| {
type: "progress";
loaded: number;
total: number;
}
| {
type: "preview";
preview: string;
}
| {
type: "completed";
cid: string;
}
| {
type: "cancel";
}
| {
type: "error";
error: string;
};
function reducer(state: State, action: Action) {
switch (action.type) {
case "progress": {
const { loaded, total } = action;
return {
...state,
progress: { loaded, total },
status: loaded === total ? "done" : state.status,
};
}
case "preview": {
return {
...state,
preview: action.preview,
};
}
case "completed": {
return {
...state,
status: "done" as UploadStatus,
cid: action.cid,
};
}
case "cancel": {
if (state.status === "progress") {
return {
...state,
status: "error" as UploadStatus,
error: "The upload has been cancelled.",
};
}
return {
progress: { loaded: 0, total: 0 },
cid: "",
preview: "",
status: "progress" as UploadStatus,
error: "",
};
}
case "error": {
return { ...state, error: action.error, status: "error" as UploadStatus };
}
default: {
return state;
}
}
}
const isImage = (type: string) => type.startsWith("image");
export function UploadFile({
file,
onClose,
id,
onSuccess,
provider,
useWorker,
}: UploadFileProps) {
const abort = useRef<(() => void) | null>(null);
const queryClient = useQueryClient();
const worker = useRef<Worker | null>(null);
const [toast, setToast] = useState({ time: 0, message: "" });
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
progress: { loaded: 0, total: 0 },
cid: "",
preview: "",
status: "progress" as UploadStatus,
error: "",
});
const { mutateAsync } = useMutation({
mutationKey: ["upload"],
mutationFn: (file: File) => {
return provider()
.then((upload) => upload(file, onProgress))
.then((res) => {
console.info("abort", res.abort);
abort.current = res.abort;
return res.result;
})
.then((safe) =>
safe.error
? Promise.reject(safe.data.message)
: Promise.resolve(safe.data)
);
},
onError: (error) => {
worker.current?.terminate();
// TODO report to Sentry
dispatch({ type: "error", error: error.message });
},
onSuccess: (cid: string) => {
onInternalSuccess(cid);
},
});
const init = useRef(false);
const onInternalSuccess = (cid: string) => {
worker.current?.terminate();
queryClient.invalidateQueries({
queryKey: ["cids"],
});
if (onSuccess) {
onSuccess(cid);
dispatch({ type: "reset" });
} else {
dispatch({ type: "completed", cid });
}
};
const onProgress = (loaded: number, total: number) => {
dispatch({
type: "progress",
loaded,
total,
});
};
useEffect(() => {
if (init.current) {
return;
}
init.current = true;
if (isImage(file.type)) {
const reader = new FileReader();
reader.onload = () => {
const preview = reader.result?.toString();
if (preview) {
dispatch({ type: "preview", preview });
}
};
reader.readAsDataURL(file);
}
if (useWorker) {
worker.current = new Worker(new URL("./worker", import.meta.url), {
type: "module",
});
provider().then((upload) => {
worker.current?.postMessage({ type: "init", upload: "" });
});
worker.current.onmessage = function (e) {
const data = e.data;
if (e.data.type === "progress") {
onProgress(data.loaded, data.total);
} else if (e.data.type === "completed") {
onInternalSuccess(e.data.value.data);
} else if (e.data.error) {
// TODO report with sentry
dispatch({ type: "error", error: e.data.error });
}
};
worker.current.onerror = function (e) {
// TODO report to sentry
console.error("Error in worker:", e);
dispatch({ type: "error", error: e.message });
worker.current?.terminate();
};
worker.current.postMessage({ type: "file", file });
} else {
console.info("running file !!");
mutateAsync(file);
}
}, []);
const onCancel = () => {
if (worker.current) {
worker.current.postMessage({ type: "abort" });
} else {
abort.current?.();
}
dispatch({ type: "cancel" });
};
const onInternalClose = () => {
if (worker.current) {
worker.current.postMessage({ type: "abort" });
} else {
abort.current?.();
}
onClose(id);
};
const onCopy = () => {
if (cid) {
navigator.clipboard.writeText(cid);
setToast({
time: Date.now(),
message: "The CID has been copied to your clipboard.",
});
}
};
const parts = file.name.split(".");
const extension = parts.pop();
const filename = parts.join(".");
const { cid, error, preview, progress, status } = state;
const onAction = state.status === "progress" ? onCancel : onInternalClose;
const percent =
progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0;
const ActionIcon = () => <UploadActionIcon status={status} />;
return (
<div className={"uploadFile"}>
<div className="uploadFile-info">
<div className="uploadFile-infoLeft">
{preview ? (
<img
src={preview}
width="24"
alt="Preview"
className="uploadFile-preview"
/>
) : (
<WebFileIcon type={file.type} />
)}
<div className="uploadFile-infoText">
<b
className="uploadFile-name"
{...attributes({
"aria-invalid": status === "error",
"data-done": status === "done",
})}
>
<span className="uploadFile-filename">{filename}</span>
<span>.{extension}</span>
</b>
<div>
<small>{PrettyBytes(file.size)}</small>
</div>
</div>
</div>
<div className="uploadFile-infoRight">
<UploadStatusIcon status={status} />
<ButtonIcon
variant="small"
onClick={onAction}
Icon={ActionIcon}
></ButtonIcon>
</div>
</div>
<div className="uploadFile-progress">
<progress
className="uploadFile-progressBar"
{...attributes({
max: file ? progress.total.toString() : false,
value: file ? progress.loaded.toString() : false,
"aria-invalid": status === "error",
})}
/>
<span className="uploadFile-progressBarPercent">
{percent.toFixed(2)} %
</span>
</div>
{!!cid && (
<>
<div className="text--primary">
<span>Success !</span> Click on the CID to copy it to your
clipboard.
</div>
<a>
<small className="uploadFile-cid" onClick={onCopy}>
{cid}
</small>
</a>
</>
)}
{error && <SimpleText variant="error">{error}</SimpleText>}
<Toast message={toast.message} time={toast.time} Icon={Info} />
</div>
);
}
type UploadStatusIconProps = {
status: UploadStatus;
};
export function UploadStatusIcon({ status }: UploadStatusIconProps) {
switch (status) {
case "done":
return (
<CircleCheck
size={"1.25rem"}
fill="currentColor"
className="upload-progress-check"
stroke="var(--codex-background)"
></CircleCheck>
);
case "error":
return (
<TriangleAlert
size={"1.25rem"}
fill="currentColor"
className="upload-progress-cancelled"
stroke="var(--codex-background)"
></TriangleAlert>
);
case "progress":
return <Spinner width={"1.25rem"} className="upload-progress-check" />;
}
}
function UploadActionIcon({ status }: UploadStatusIconProps) {
switch (status) {
case "error":
case "done":
return <CircleX size={"1.25rem"} />;
case "progress":
return <CircleStop size={"1.25rem"} />;
}
}