388 lines
9.1 KiB
TypeScript

import {
useRef,
useState,
useReducer,
Reducer,
useEffect,
useCallback,
} 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 } from "lucide-react";
import { Spinner } from "../Spinner/Spinner";
import { CodexData } from "@codex-storage/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, file: File) => void) | undefined;
codexData: CodexData;
// 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: "delete";
}
| {
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,
// Just to ensure the file upload is in done status,
// in case of the onprogress callback function was not called
status: "done" as UploadStatus,
cid: action.cid,
};
}
case "cancel": {
return {
...state,
status: "error" as UploadStatus,
error: "The upload has been cancelled.",
};
}
case "delete": {
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,
codexData,
// useWorker,
}: UploadFileProps) {
const abort = useRef<(() => void) | null>(null);
// 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 upload = useCallback(async () => {
const { abort: a, result } = codexData.upload(file, onProgress);
abort.current = a;
const res = await result;
if (res.error) {
dispatch({ type: "error", error: res.data.message });
return;
}
dispatch({ type: "completed", cid: res.data });
onSuccess?.(res.data, file);
}, [codexData, onSuccess, file]);
const init = useRef(false);
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);
}
upload();
// if (useWorker) {
// worker.current = new Worker(new URL("./worker", import.meta.url), {
// type: "module",
// });
// provider().then(() => {
// 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 {
// mutateAsync(file);
// }
}, [file, upload, codexData]);
const onCancel = () => {
// if (worker.current) {
// worker.current.postMessage({ type: "abort" });
// } else {
// abort.current?.();
// }
abort.current?.();
const type = state.status === "progress" ? "cancel" : "delete";
dispatch({ type });
};
const onInternalClose = () => {
// if (worker.current) {
// worker.current.postMessage({ type: "abort" });
// } else {
// abort.current?.();
// }
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>
</>
)}
<SimpleText variant="error">{error ? error : <>&nbsp;</>}</SimpleText>
<Toast message={toast.message} time={toast.time} variant="success" />
</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"} />;
}
}