parcel/scripts/components/upload.ts
Blake Rain bdb5d9113c
All checks were successful
Check / check (push) Successful in 1m7s
feat: allow dropping files into index page to open upload modal
2024-08-09 10:07:20 +01:00

259 lines
6.5 KiB
TypeScript

import { FunctionComponent, VNode } from "preact";
import { useEffect, useRef } from "preact/hooks";
import { html } from "htm/preact";
import register from "preact-custom-element";
import { useState, ProvideState, StateMode, StateAction } from "./upload/state";
import DropZone from "./upload/components/dropzone";
import FilesSummary from "./upload/components/summary";
import FilesList from "./upload/components/list";
import UploadProgress from "./upload/components/progress";
import { ParcelModal } from "./modal";
import { FileInfo } from "./upload/files";
function startUpload(
modal: ParcelModal,
csrf_token: string,
files: FileInfo[],
dispatch: (action: StateAction) => void,
) {
const form = new FormData();
form.append("csrf_token", csrf_token);
for (let file of files) {
form.append("file", file.file);
}
const upload = new XMLHttpRequest();
upload.addEventListener("load", () => {
modal.setUnderlayDismiss(true);
htmx.trigger("#upload-list-refresh", "refresh");
dispatch({ type: "complete" });
});
upload.addEventListener("error", (event) => {
console.error("Failed to upload file", event);
modal.setUnderlayDismiss(true);
dispatch({ type: "error", event });
});
upload.addEventListener("abort", (event) => {
console.warn("Upload was aborted", event);
modal.setUnderlayDismiss(true);
dispatch({ type: "abort", event });
});
upload.upload.addEventListener("progress", (event) => {
dispatch({ type: "progress", loaded: event.loaded });
});
modal.setUnderlayDismiss(false);
upload.open("POST", "/uploads/new");
upload.send(form);
dispatch({ type: "upload", upload });
}
const UploadButtons: FunctionComponent<{ csrf_token: string }> = (props) => {
const { state, dispatch } = useState();
const onCancelClick = (event: MouseEvent) => {
if (state.upload) {
state.upload.abort();
} else {
(event.target as HTMLElement)
.closest<ParcelModal>("parcel-modal")
.closeModal();
}
};
const onUploadClick = (event: MouseEvent) => {
const modal = (event.target as HTMLElement).closest<ParcelModal>(
"parcel-modal",
);
if (!modal) {
throw new Error("Could not find parent modal");
}
startUpload(modal, props.csrf_token, state.files, dispatch);
};
return html`
<div class="buttons end">
<button
type="button"
class="button hollow ${state.upload && "danger"}"
onclick=${onCancelClick}
>
<span class="icon-x"></span>
Cancel${state.upload && " upload"}
</button>
<button
type="button"
class="button"
disabled=${state.files.length === 0 || state.upload}
onclick=${onUploadClick}
>
<span class="icon-upload"></span>
Upload file
</button>
</div>
`;
};
const CompleteButtons: FunctionComponent = () => {
const { dispatch } = useState();
const onMoreClick = () => {
dispatch({ type: "reset" });
};
const onCloseClick = (event: MouseEvent) => {
(event.target as HTMLElement)
.closest<ParcelModal>("parcel-modal")
.closeModal();
};
return html`
<div class="buttons end">
<button type="button" class="button hollow" onclick=${onMoreClick}>
<span class="icon-rotate-ccw"></span>
Upload more
</button>
<button type="button" class="button success" onclick=${onCloseClick}>
<span class="icon-check"></span>
Finish
</button>
</div>
`;
};
const ErrorButtons: FunctionComponent<{ csrf_token: string }> = (props) => {
const { state, dispatch } = useState();
const onCancelClick = (event: MouseEvent) => {
(event.target as HTMLElement)
.closest<ParcelModal>("parcel-modal")
.closeModal();
};
const onResetClick = () => {
dispatch({ type: "reset" });
};
const onRetryClick = (event: MouseEvent) => {
const modal = (event.target as HTMLElement).closest<ParcelModal>(
"parcel-modal",
);
if (!modal) {
throw new Error("Could not find parent modal");
}
startUpload(modal, props.csrf_token, state.files, dispatch);
};
return html`
<div class="buttons end">
<button
type="button"
class="button hollow danger"
onclick=${onCancelClick}
>
<span class="icon-x"></span>
Cancel
</button>
<button type="button" class="button hollow" onclick=${onResetClick}>
<span class="icon-rotate-ccw"></span>
Reset
</button>
<button type="button" class="button" onclick=${onRetryClick}>
<span class="icon-upload"></span>
Try again
</button>
</div>
`;
};
const UploadBody = () => {
const { state } = useState();
if (state.files.length === 0) {
return html`<div></div>`;
}
return html`
<div
class="border border-gray-300 dark:border-slate-600 rounded-md flex flex-col gap-2 overflow-y-hidden"
>
${state.upload
? html` <${UploadProgress} /> `
: html` <${FilesSummary} /> `}
<div class="overflow-y-scroll px-4 mb-4">
<${FilesList} />
</div>
</div>
`;
};
const UploadFormInner: FunctionComponent<{ csrf_token: string }> = (props) => {
const eventRecv = useRef<HTMLElement>(null);
const { state, dispatch } = useState();
let buttons: VNode;
switch (state.mode) {
case StateMode.Preparing:
case StateMode.Uploading:
buttons = html`<${UploadButtons} ...${props} />`;
break;
case StateMode.Error:
case StateMode.Aborted:
buttons = html`<${ErrorButtons} ...${props} />`;
break;
case StateMode.Complete:
buttons = html`<${CompleteButtons} />`;
break;
}
useEffect(() => {
if (!eventRecv.current) {
return;
}
const element = eventRecv.current;
const onParcelDrop = (event: CustomEvent<{ files: File[] }>) => {
dispatch({
type: "add",
files: event.detail.files,
});
};
element.addEventListener("parcelDrop", onParcelDrop);
return () => {
element.removeEventListener("parcelDrop", onParcelDrop);
};
});
return html`
<div ref=${eventRecv} class="invisible event-receiver"></div>
<div
class="grid grid-rows-[max-content_1fr_max-content] max-h-[80vh] gap-4"
>
<${DropZone} />
<${UploadBody} />
${buttons}
</div>
`;
};
const UploadForm: FunctionComponent<{ csrf_token: string }> = (props) => {
return html`
<${ProvideState}>
<${UploadFormInner} ...${props} />
</${ProvideState}>
`;
};
register(UploadForm, "parcel-upload-form", ["csrf_token"]);